mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+18
-1214
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }>) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
fetchRecoveries,
|
||||
fetchRecoveryByCycleId,
|
||||
fetchSleepById,
|
||||
mapRecovery,
|
||||
} from "./client";
|
||||
import { mapRecovery } from "./map";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user