merge(oura): real sleep timeline, SpO2, sleep score, temperature deviation, contributors + reconcile

# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	prisma/schema.prisma
#	src/components/measurements/measurement-list-meta.ts
#	src/lib/__tests__/measurement-type-enum-coverage.test.ts
#	src/lib/insights/chart-tokens.ts
#	src/lib/measurements/__tests__/apple-health-mapping.test.ts
#	src/lib/measurements/categories.ts
#	src/lib/personal-records/pr-direction.ts
#	src/lib/validations/measurement.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:17:51 +02:00
commit 2736e23092
26 files changed
+873 -50

No files matched your search

+10
View File
@@ -1571,6 +1571,8 @@ paths:
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
- in: query
name: from
schema:
@@ -1747,6 +1749,8 @@ paths:
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
value:
type: number
measuredAt:
@@ -4378,6 +4382,8 @@ paths:
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
required: true
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
@@ -8849,6 +8855,8 @@ components:
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
value:
type: number
unit:
@@ -9280,6 +9288,8 @@ components:
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
value:
type: number
unit:
+2
View File
@@ -552,6 +552,8 @@
"typeSleepDisturbanceCount": "Schlafstörungen",
"typeAnsCharge": "ANS-Ladung",
"typeCardioLoad": "Kardio-Belastung",
"typeSleepScore": "Schlafscore",
"typeBodyTemperatureDeviation": "Körpertemperaturabweichung",
"filterBySource": "Nach Quelle filtern",
"sourceComputed": "Berechnet",
"sourceWhoop": "WHOOP",
+2
View File
@@ -552,6 +552,8 @@
"typeSleepDisturbanceCount": "Sleep disturbances",
"typeAnsCharge": "ANS charge",
"typeCardioLoad": "Cardio load",
"typeSleepScore": "Sleep score",
"typeBodyTemperatureDeviation": "Body temperature deviation",
"filterBySource": "Filter by source",
"sourceComputed": "Computed",
"sourceWhoop": "WHOOP",
+2
View File
@@ -552,6 +552,8 @@
"typeSleepDisturbanceCount": "Interrupciones del sueño",
"typeAnsCharge": "Carga del SNA",
"typeCardioLoad": "Carga cardio",
"typeSleepScore": "Puntuación de sueño",
"typeBodyTemperatureDeviation": "Desviación de temperatura corporal",
"filterBySource": "Filtrar por fuente",
"sourceComputed": "Calculado",
"sourceWhoop": "WHOOP",
+2
View File
@@ -552,6 +552,8 @@
"typeSleepDisturbanceCount": "Perturbations du sommeil",
"typeAnsCharge": "Charge SNA",
"typeCardioLoad": "Charge cardio",
"typeSleepScore": "Score de sommeil",
"typeBodyTemperatureDeviation": "Écart de température corporelle",
"filterBySource": "Filtrer par source",
"sourceComputed": "Calculé",
"sourceWhoop": "WHOOP",
+2
View File
@@ -552,6 +552,8 @@
"typeSleepDisturbanceCount": "Disturbi del sonno",
"typeAnsCharge": "Carica SNA",
"typeCardioLoad": "Carico cardio",
"typeSleepScore": "Punteggio del sonno",
"typeBodyTemperatureDeviation": "Deviazione della temperatura corporea",
"filterBySource": "Filtra per fonte",
"sourceComputed": "Calcolato",
"sourceWhoop": "WHOOP",
+2
View File
@@ -552,6 +552,8 @@
"typeSleepDisturbanceCount": "Zakłócenia snu",
"typeAnsCharge": "Ładunek ANS",
"typeCardioLoad": "Obciążenie kardio",
"typeSleepScore": "Wynik snu",
"typeBodyTemperatureDeviation": "Odchylenie temperatury ciała",
"filterBySource": "Filtruj według źródła",
"sourceComputed": "Obliczone",
"sourceWhoop": "WHOOP",
@@ -0,0 +1,20 @@
-- v1.17.1 — Oura coverage completion (additive).
--
-- Two new `measurement_type` enum values so the Oura sync can store signals it
-- already fetches (or now fetches) but had no home for:
--
-- 1. `SLEEP_SCORE` — Oura's headline 0–100 Sleep Score from the
-- `daily_sleep` collection (distinct from the per-stage `sleep` detail).
-- Kept apart from the WHOOP `SLEEP_PERFORMANCE` / `SLEEP_EFFICIENCY`
-- sub-scores so two vendors' headline numbers never share a bucket.
-- 2. `BODY_TEMPERATURE_DEVIATION` — Oura's nightly body-temperature deviation
-- from the user's personal baseline (`daily_readiness.temperature_deviation`),
-- a SIGNED °C offset (illness / luteal-phase / stress signal). It is NOT an
-- absolute reading, so it cannot share the BODY_TEMPERATURE /
-- SKIN_TEMPERATURE / WRIST_TEMPERATURE absolute-temperature buckets.
--
-- Purely-additive: two enum extensions, no backfill, no existing row touched,
-- no new tables or columns. Both stored as `source = OURA`.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE_DEVIATION';
+12
View File
@@ -784,6 +784,18 @@ enum MeasurementType {
// each device-native value keeps its own bucket and native scale.
ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment.
CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket.
// ── v1.17.1 — Oura coverage completion (additive) ──
// Oura's headline 0–100 Sleep Score from the `daily_sleep` collection
// (distinct from the per-stage `sleep` detail). Stored as `source = OURA`,
// unit `score`. Kept apart from SLEEP_PERFORMANCE / SLEEP_EFFICIENCY (WHOOP
// sub-scores) so two vendors' headline numbers never share a bucket.
SLEEP_SCORE // score (0–100) — Oura `daily_sleep.score`. Per-night sleep quality headline.
// Oura nightly body-temperature deviation from the user's personal baseline
// (`daily_readiness.temperature_deviation`), in °C. NOT an absolute
// temperature — a signed offset (illness / luteal-phase / stress signal),
// so it cannot share the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE
// absolute-reading buckets. Plausible band is small and centred on 0.
BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`.
@@map("measurement_type")
}
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { update: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setError: vi.fn(), setAuth: vi.fn() }),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() }));
const { exchangeMock, getCredsMock, matchMock, verifyMock } = vi.hoisted(() => ({
exchangeMock: vi.fn(),
getCredsMock: vi.fn(),
matchMock: vi.fn(),
verifyMock: vi.fn(),
}));
vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock }));
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
oauthStateCookieName: (p: string) => `${p}_state`,
stateMatchesCookie: matchMock,
verifySignedState: verifyMock,
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import type { NextRequest } from "next/server";
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function makeReq(opts: { code?: string; state?: string; cookie?: string }): NextRequest {
const params = new URLSearchParams();
if (opts.code) params.set("code", opts.code);
if (opts.state) params.set("state", opts.state);
return {
url: `https://app.example/api/oura/callback?${params}`,
cookies: {
get: (name: string) =>
name === "oura_state" && opts.cookie ? { value: opts.cookie } : undefined,
},
} as unknown as NextRequest;
}
beforeEach(() => {
vi.clearAllMocks();
matchMock.mockReturnValue(true);
verifyMock.mockReturnValue({ userId: "u1" });
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
exchangeMock.mockResolvedValue({ access_token: "at", refresh_token: "rt" });
userUpdate.mockResolvedValue({});
(getSession as ReturnType<typeof vi.fn>).mockResolvedValue(null);
});
describe("GET /api/oura/callback", () => {
it("rejects a cookie/state mismatch as csrf1 before any exchange", async () => {
matchMock.mockReturnValue(false);
const res = await GET(makeReq({ code: "c", state: "S", cookie: "X" }));
expect(res.headers.get("location")).toContain("reason=csrf1");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("rejects an unverifiable signed state", async () => {
verifyMock.mockReturnValue(null);
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("reason=state");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("rejects when the verified userId mismatches a live session (cross_user)", async () => {
(getSession as ReturnType<typeof vi.fn>).mockResolvedValue({
user: { id: "attacker" },
});
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("reason=cross_user");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("falls back to nocreds when no BYO/env credentials resolve", async () => {
getCredsMock.mockResolvedValue(null);
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("reason=nocreds");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("exchanges the code, persists encrypted tokens, and audits on success", async () => {
const res = await GET(makeReq({ code: "auth-code", state: "S", cookie: "S" }));
expect(exchangeMock).toHaveBeenCalledWith("auth-code", {
clientId: "c",
clientSecret: "s",
});
const data = userUpdate.mock.calls[0]![0].data;
expect(data.ouraAccessTokenEncrypted).toBe("enc:at");
expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt");
expect(auditLog).toHaveBeenCalledWith("oura.connect", { userId: "u1" });
expect(res.headers.get("location")).toContain("oura=connected");
});
});
+3 -1
View File
@@ -15,7 +15,9 @@ import { NextResponse } from "next/server";
/**
* v1.17.0 (F4) — redirect the user to the Oura consent screen.
*
* Full OAuth from env (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`). CSRF via the
* Credentials resolve DB-first then env (`getOuraClientCredentials`): a user's
* BYO Oura client id/secret wins, falling back to the shared env app
* (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the
* stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is
* the `state` URL param AND an httpOnly cookie. Rate-limited per user.
*/
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
const { storeMock, clearMock } = vi.hoisted(() => ({
storeMock: vi.fn(),
clearMock: vi.fn(),
}));
vi.mock("@/lib/oura/credentials", () => ({
storeOuraClientCredentials: storeMock,
clearOuraClientCredentials: clearMock,
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: vi.fn(),
}));
import { GET, PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
import { safeJson } from "@/lib/api-response";
import type { NextRequest } from "next/server";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
const safeJsonMock = safeJson as ReturnType<typeof vi.fn>;
type RouteResult = { data: unknown; error: string | null; status: number };
beforeEach(() => {
vi.clearAllMocks();
userFind.mockResolvedValue(null);
userUpdate.mockResolvedValue({});
storeMock.mockResolvedValue(undefined);
clearMock.mockResolvedValue(undefined);
});
describe("GET /api/oura/credentials", () => {
it("reports hasCredentials true only when both columns are set", async () => {
userFind.mockResolvedValue({
ouraClientIdEncrypted: "enc:id",
ouraClientSecretEncrypted: "enc:secret",
});
const res = (await (GET as unknown as () => Promise<RouteResult>)())!;
expect(res.data).toEqual({ hasCredentials: true });
});
it("reports false when a column is missing", async () => {
userFind.mockResolvedValue({ ouraClientIdEncrypted: "enc:id" });
const res = (await (GET as unknown as () => Promise<RouteResult>)())!;
expect(res.data).toEqual({ hasCredentials: false });
});
});
describe("PUT /api/oura/credentials", () => {
const put = PUT as unknown as (r: NextRequest) => Promise<RouteResult>;
const req = {} as NextRequest;
it("422s when the body fails the zod schema", async () => {
safeJsonMock.mockResolvedValue({ data: { clientId: "" }, error: null });
const res = await put(req);
expect(res.status).toBe(422);
expect(storeMock).not.toHaveBeenCalled();
});
it("encrypts and stores a valid client id/secret", async () => {
safeJsonMock.mockResolvedValue({
data: { clientId: "cid", clientSecret: "csecret" },
error: null,
});
const res = await put(req);
expect(res.status).toBe(200);
expect(storeMock).toHaveBeenCalledWith("u1", "cid", "csecret");
});
it("propagates the safeJson error envelope", async () => {
const jsonError = { data: null, error: "too big", status: 413 };
safeJsonMock.mockResolvedValue({ data: null, error: jsonError });
const res = await put(req);
expect(res).toBe(jsonError);
expect(storeMock).not.toHaveBeenCalled();
});
});
describe("DELETE /api/oura/credentials", () => {
const del = DELETE as unknown as () => Promise<RouteResult>;
it("audits + marks disconnected when a live token was present", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc:tok" });
const res = await del();
expect(res.data).toEqual({ deleted: true });
expect(clearMock).toHaveBeenCalledWith("u1");
expect(userUpdate).toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith("oura.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "oura");
});
it("skips audit + ledger update when nothing was connected", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: null });
const res = await del();
expect(res.data).toEqual({ deleted: true });
expect(clearMock).toHaveBeenCalledWith("u1");
expect(auditLog).not.toHaveBeenCalled();
expect(markDisconnected).not.toHaveBeenCalled();
});
});
+18
View File
@@ -2,6 +2,8 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
import {
storeOuraClientCredentials,
clearOuraClientCredentials,
@@ -63,11 +65,22 @@ export const PUT = apiHandler(async (request: NextRequest) => {
/**
* Delete Oura credentials and the active connection.
*
* Deleting the BYO credentials drops a live connection (a token minted against
* the deleted app is now orphaned), so when an access token was present this
* mirrors `/api/oura/disconnect`: audit the event and park the integration
* ledger at `disconnected` rather than leaving it stale at its last state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.credentials.delete" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
});
const wasConnected = !!dbUser?.ouraAccessTokenEncrypted;
await prisma.user.update({
where: { id: user.id },
data: {
@@ -77,5 +90,10 @@ export const DELETE = apiHandler(async () => {
});
await clearOuraClientCredentials(user.id);
if (wasConnected) {
await auditLog("oura.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "oura");
}
return apiSuccess({ deleted: true });
});
@@ -117,6 +117,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "measurements.typeAnsCharge",
CARDIO_LOAD: "measurements.typeCardioLoad",
// ── v1.17.1 — Oura coverage completion ──
SLEEP_SCORE: "measurements.typeSleepScore",
BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -233,6 +236,9 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// cardio-load strain figure (same family as DAY_STRAIN).
ANS_CHARGE: HeartPulse,
CARDIO_LOAD: Gauge,
// ── v1.17.1 — Oura coverage completion ──
SLEEP_SCORE: Moon,
BODY_TEMPERATURE_DEVIATION: Thermometer,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -338,4 +344,9 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
// chart-1 (strain/score family) for both, matching DAY_STRAIN.
ANS_CHARGE: "bg-chart-1/20 text-chart-1",
CARDIO_LOAD: "bg-chart-1/20 text-chart-1",
// ── v1.17.1 — Oura coverage completion ──
// chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal)
// for the body-temperature deviation.
SLEEP_SCORE: "bg-chart-2/20 text-chart-2",
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/20 text-chart-5",
};
@@ -93,6 +93,9 @@ const EXPECTED_TYPES = [
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
"ANS_CHARGE",
"CARDIO_LOAD",
// ── v1.17.1 — Oura coverage completion ──
"SLEEP_SCORE",
"BODY_TEMPERATURE_DEVIATION",
] as const;
describe("measurementTypeEnum coverage", () => {
@@ -225,6 +228,14 @@ describe("measurementTypeEnum coverage", () => {
// clinical vitals PDF. See doctor-report-pdf-core.ts.
"ANS_CHARGE",
"CARDIO_LOAD",
// v1.17.1 — Oura coverage completion. The Sleep Score is a nightly
// derived composite and the body-temperature deviation is a signed
// baseline offset (illness / cycle / stress signal), not a measured
// clinical vital — both surface on the Insights / measurement-list
// surfaces and never belong in the clinical vitals PDF. See
// doctor-report-pdf-core.ts for the matching exclusion rationale.
"SLEEP_SCORE",
"BODY_TEMPERATURE_DEVIATION",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+5
View File
@@ -188,6 +188,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* and carry a "descriptive, not a clinical assessment" disclaimer. They stay
* out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in
* `measurement-type-enum-coverage.test.ts`).
*
* v1.17.1 — Oura coverage completion. `SLEEP_SCORE` (a nightly derived
* composite) and `BODY_TEMPERATURE_DEVIATION` (a signed baseline offset, not an
* absolute reading) are excluded for the same reason as the other derived /
* lifestyle signals — they are descriptive, not measured clinical vitals.
*/
export const DOCTOR_REPORT_VITAL_TYPES = [
"WEIGHT",
+8
View File
@@ -120,6 +120,10 @@ export const ALLOWED_CHART_TOKENS = [
// generic chart renderer alongside the WHOOP-native score classes.
"metric:ANS_CHARGE",
"metric:CARDIO_LOAD",
// v1.17.1 — Oura coverage completion. Both are continuous nightly series, so
// each carries a `metric:<TYPE>` token and renders through the generic chart.
"metric:SLEEP_SCORE",
"metric:BODY_TEMPERATURE_DEVIATION",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -235,6 +239,10 @@ const ORPHAN_ENUMS = [
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
// v1.17.1 — Oura coverage completion, same shape: strip the bare enum name
// if the model drops it into prose.
"SLEEP_SCORE",
"BODY_TEMPERATURE_DEVIATION",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
@@ -66,6 +66,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// its device-native cardio-load strain figure. No HK mapping by design.
"ANS_CHARGE",
"CARDIO_LOAD",
// v1.17.1 — Oura coverage completion. The Sleep Score and the body-temperature
// deviation ingest server-side from the Oura API (source = OURA), never from
// HealthKit: Apple ships no headline 0–100 sleep score and surfaces only an
// absolute wrist temperature, not a signed baseline deviation. No HK mapping
// by design.
"SLEEP_SCORE",
"BODY_TEMPERATURE_DEVIATION",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
+6
View File
@@ -194,6 +194,12 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
// so they join the scores cluster alongside DAY_STRAIN.
["ANS_CHARGE", "scores"],
["CARDIO_LOAD", "scores"],
// ── v1.17.1 — Oura coverage completion ──
// The Sleep Score is a per-night sleep-quality index → sleep cluster.
["SLEEP_SCORE", "sleep"],
// Body-temperature deviation is an overnight thermal signal that pairs with
// skin / wrist temperature in the metabolic cluster.
["BODY_TEMPERATURE_DEVIATION", "metabolic"],
]);
/**
+125 -6
View File
@@ -7,10 +7,14 @@ import {
getAuthorizationUrl,
getOuraCredentials,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapReadiness,
mapSleep,
refreshAccessToken,
type OuraDailyActivity,
type OuraDailySleep,
type OuraDailySpo2,
type OuraReadiness,
type OuraSleep,
} from "../client";
@@ -122,18 +126,41 @@ describe("mapReadiness", () => {
it("maps score -> RECOVERY_SCORE", () => {
const r: OuraReadiness = { id: "1", day: "2026-06-10", score: 84 };
const mapped = mapReadiness(r);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({ type: "RECOVERY_SCORE", value: 84, fieldTag: "recovery" });
expect(mapped.find((m) => m.type === "RECOVERY_SCORE")).toMatchObject({
value: 84,
fieldTag: "recovery",
});
});
it("skips a record with no score", () => {
it("maps temperature_deviation -> BODY_TEMPERATURE_DEVIATION (signed °C)", () => {
const r: OuraReadiness = {
id: "1",
day: "2026-06-10",
score: 70,
temperature_deviation: -0.42,
};
const mapped = mapReadiness(r);
const dev = mapped.find((m) => m.type === "BODY_TEMPERATURE_DEVIATION");
expect(dev).toMatchObject({ value: -0.42, unit: "celsius", fieldTag: "temp_deviation" });
});
it("emits the temperature deviation even when the score is absent", () => {
const mapped = mapReadiness({
id: "1",
day: "2026-06-10",
score: null,
temperature_deviation: 0.3,
});
expect(mapped).toHaveLength(1);
expect(mapped[0].type).toBe("BODY_TEMPERATURE_DEVIATION");
});
it("skips a record with no score and no temperature", () => {
expect(mapReadiness({ id: "1", day: "2026-06-10", score: null })).toEqual([]);
});
});
describe("mapSleep", () => {
it("maps stages s->min, efficiency, hrv, rhr, breath", () => {
it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => {
const s: OuraSleep = {
id: "1",
id: "rec-9",
day: "2026-06-10",
light_sleep_duration: 3600,
deep_sleep_duration: 1800,
@@ -151,22 +178,114 @@ describe("mapSleep", () => {
expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(48);
expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(49);
expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.5);
// B2 — every sleep row is record-scoped so a nap never overwrites the main.
expect(
mapped.every((m) => m.externalId?.startsWith("sleep:rec-9:")),
).toBe(true);
});
it("emits a real timed timeline from sleep_phase_5_min (reconstructed=false)", () => {
// 2× deep (1), 3× light (2), 1× rem (3), 1× awake (4) = 7 five-min intervals.
const s: OuraSleep = {
id: "rec-T",
day: "2026-06-10",
bedtime_start: "2026-06-09T23:00:00.000Z",
bedtime_end: "2026-06-10T07:00:00.000Z",
sleep_phase_5_min: "1122234",
};
const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION");
// Four contiguous runs → four segments, in onset order.
expect(mapped.map((m) => m.sleepStage)).toEqual([
"DEEP",
"CORE",
"REM",
"AWAKE",
]);
// Run lengths × 5 min: 2→10, 3→15, 1→5, 1→5.
expect(mapped.map((m) => m.value)).toEqual([10, 15, 5, 5]);
// Each segment stamped at its own END instant relative to bedtime_start.
const onset = new Date("2026-06-09T23:00:00.000Z").getTime();
expect(mapped[0].measuredAt.getTime()).toBe(onset + 10 * 60_000);
expect(mapped[1].measuredAt.getTime()).toBe(onset + 25 * 60_000);
// Distinct, record-scoped, segment-indexed externalIds (no collapse).
const ids = mapped.map((m) => m.externalId);
expect(new Set(ids).size).toBe(ids.length);
expect(ids[0]).toBe("sleep:rec-T:seg:0");
});
it("ignores an empty / malformed hypnogram and uses stage totals", () => {
const s: OuraSleep = {
id: "rec-E",
day: "2026-06-10",
bedtime_start: "2026-06-09T23:00:00.000Z",
sleep_phase_5_min: "",
deep_sleep_duration: 1800,
};
const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION");
expect(mapped).toHaveLength(1);
expect(mapped[0].sleepStage).toBe("DEEP");
expect(mapped[0].fieldTag).toBe("sleep_deep");
});
});
describe("mapDailyActivity", () => {
it("maps steps and active energy", () => {
it("maps steps, active energy, and equivalent walking distance (m)", () => {
const a: OuraDailyActivity = {
id: "1",
day: "2026-06-10",
steps: 9001,
active_calories: 412,
equivalent_walking_distance: 6543,
};
const mapped = mapDailyActivity(a);
expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(9001);
expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe(
412,
);
const dist = mapped.find((m) => m.type === "WALKING_RUNNING_DISTANCE");
expect(dist).toMatchObject({ value: 6543, unit: "m", fieldTag: "distance" });
});
});
describe("mapDailySleep", () => {
it("maps the Sleep Score -> SLEEP_SCORE", () => {
const d: OuraDailySleep = { id: "1", day: "2026-06-10", score: 82 };
const mapped = mapDailySleep(d);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({
type: "SLEEP_SCORE",
value: 82,
unit: "score",
fieldTag: "sleep_score",
});
});
it("skips a record with no score", () => {
expect(mapDailySleep({ id: "1", day: "2026-06-10", score: null })).toEqual(
[],
);
});
});
describe("mapDailySpo2", () => {
it("maps the average SpO2 -> OXYGEN_SATURATION", () => {
const s: OuraDailySpo2 = {
id: "1",
day: "2026-06-10",
spo2_percentage: { average: 96.8 },
};
const mapped = mapDailySpo2(s);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({
type: "OXYGEN_SATURATION",
value: 96.8,
unit: "%",
fieldTag: "spo2",
});
});
it("skips a record with no average", () => {
expect(
mapDailySpo2({ id: "1", day: "2026-06-10", spo2_percentage: { average: null } }),
).toEqual([]);
});
});
+51
View File
@@ -7,6 +7,8 @@ const {
fetchReadinessMock,
fetchSleepMock,
fetchActivityMock,
fetchDailySleepMock,
fetchSpo2Mock,
refreshMock,
upsertMock,
recordSuccessMock,
@@ -20,6 +22,8 @@ const {
fetchReadinessMock: vi.fn(),
fetchSleepMock: vi.fn(),
fetchActivityMock: vi.fn(),
fetchDailySleepMock: vi.fn(),
fetchSpo2Mock: vi.fn(),
refreshMock: vi.fn(),
upsertMock: vi.fn(),
recordSuccessMock: vi.fn(),
@@ -61,6 +65,8 @@ vi.mock("../client", async (importOriginal) => {
fetchReadiness: fetchReadinessMock,
fetchSleep: fetchSleepMock,
fetchDailyActivity: fetchActivityMock,
fetchDailySleep: fetchDailySleepMock,
fetchDailySpo2: fetchSpo2Mock,
refreshAccessToken: refreshMock,
};
});
@@ -79,6 +85,8 @@ beforeEach(() => {
fetchReadinessMock.mockReset().mockResolvedValue([]);
fetchSleepMock.mockReset().mockResolvedValue([]);
fetchActivityMock.mockReset().mockResolvedValue([]);
fetchDailySleepMock.mockReset().mockResolvedValue([]);
fetchSpo2Mock.mockReset().mockResolvedValue([]);
refreshMock.mockReset();
upsertMock.mockReset().mockResolvedValue({});
recordSuccessMock.mockReset().mockResolvedValue(undefined);
@@ -159,6 +167,49 @@ describe("syncUserOura", () => {
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("keeps a nap and the main sleep distinct via record-scoped externalIds (B2)", async () => {
getConnMock.mockResolvedValue(CONN);
// Two sleep records on the same day: a main sleep + a nap.
fetchSleepMock.mockResolvedValue([
{ id: "main", day: "2026-06-10", deep_sleep_duration: 3600 },
{ id: "nap", day: "2026-06-10", deep_sleep_duration: 1200 },
]);
await syncUserOura("u1");
const externalIds = upsertMock.mock.calls.map(
(c) => c[0].where.userId_type_source_externalId.externalId,
);
expect(externalIds).toContain("sleep:main:sleep_deep");
expect(externalIds).toContain("sleep:nap:sleep_deep");
// The legacy day-keyed collapse would have produced one shared key.
expect(new Set(externalIds).size).toBe(externalIds.length);
});
it("writes the Sleep Score and SpO2 from the new collections", async () => {
getConnMock.mockResolvedValue(CONN);
fetchDailySleepMock.mockResolvedValue([
{ id: "s", day: "2026-06-10", score: 80 },
]);
fetchSpo2Mock.mockResolvedValue([
{ id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } },
]);
await syncUserOura("u1");
const written = upsertMock.mock.calls.map((c) => ({
type: c[0].where.userId_type_source_externalId.type,
externalId: c[0].where.userId_type_source_externalId.externalId,
value: c[0].create.value,
}));
expect(written).toContainEqual({
type: "SLEEP_SCORE",
externalId: "daily_sleep:2026-06-10:sleep_score",
value: 80,
});
expect(written).toContainEqual({
type: "OXYGEN_SATURATION",
externalId: "spo2:2026-06-10:spo2",
value: 97,
});
});
it("does NOT refresh on a 403 (not an expiry case)", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockRejectedValue(
+280 -27
View File
@@ -26,7 +26,12 @@ export interface OuraCredentials {
clientSecret: string;
}
/** Env-configured shared OAuth credentials (F4 = full OAuth from env). */
/**
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
* has not registered their own BYO Oura client id/secret. As of v1.17.1 the
* per-user resolver `getOuraClientCredentials` (DB-first) is the primary path
* and calls this only when no per-user pair is stored.
*/
export function getOuraCredentials(): OuraCredentials | null {
const clientId = process.env.OURA_CLIENT_ID;
const clientSecret = process.env.OURA_CLIENT_SECRET;
@@ -143,6 +148,9 @@ export interface OuraCollection<T> {
export interface OuraSleep {
id: string;
day: string;
/** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */
type?: string | null;
bedtime_start?: string;
bedtime_end?: string;
total_sleep_duration?: number | null;
time_in_bed?: number | null;
@@ -155,6 +163,33 @@ export interface OuraSleep {
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 {
@@ -162,6 +197,26 @@ export interface OuraReadiness {
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;
}
export interface OuraDailyActivity {
@@ -171,6 +226,8 @@ export interface OuraDailyActivity {
steps?: number | null;
active_calories?: number | null;
total_calories?: number | null;
/** Equivalent walking distance for the day, metres. */
equivalent_walking_distance?: number | null;
}
interface DateRangeQuery {
@@ -269,6 +326,30 @@ export function fetchDailyActivity(
);
}
export function fetchDailySleep(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraDailySleep[]> {
return fetchCollection<OuraDailySleep>(
"/v2/usercollection/daily_sleep",
accessToken,
"fetchDailySleep",
query,
);
}
export function fetchDailySpo2(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraDailySpo2[]> {
return fetchCollection<OuraDailySpo2>(
"/v2/usercollection/daily_spo2",
accessToken,
"fetchDailySpo2",
query,
);
}
// ─── Field → Measurement mapping ───────────────────────────────
const SEC_TO_MIN = 1 / 60;
@@ -284,6 +365,15 @@ export interface MappedMeasurement {
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, bedtimeEnd?: string): Date | null {
@@ -294,48 +384,157 @@ function dayAnchor(day: string, bedtimeEnd?: string): Date | null {
return Number.isNaN(d.getTime()) ? null : d;
}
/** Map one Oura readiness record: `score` → `RECOVERY_SCORE` (source OURA,
* feeding the recovery ladder below WHOOP). */
/**
* 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): MappedMeasurement[] {
if (typeof r.score !== "number") return [];
const measuredAt = dayAnchor(r.day, r.timestamp);
if (!measuredAt) return [];
return [
{
const out: MappedMeasurement[] = [];
if (typeof r.score === "number") {
out.push({
type: "RECOVERY_SCORE",
value: round2(r.score),
unit: "score",
measuredAt,
fieldTag: "recovery",
},
];
});
}
if (typeof r.temperature_deviation === "number") {
out.push({
type: "BODY_TEMPERATURE_DEVIATION",
value: round2(r.temperature_deviation),
unit: "celsius",
measuredAt,
fieldTag: "temp_deviation",
});
}
return out;
}
/** Map one Oura sleep record: per-stage durations (s→min), efficiency, HRV,
* RHR (lowest_heart_rate), respiratory rate. */
/**
* 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 segIndex = 0;
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;
const fieldTag = `seg:${segIndex}`;
out.push({
type: "SLEEP_DURATION",
value: round2(run * HYPNOGRAM_INTERVAL_MIN),
unit: "minutes",
measuredAt: new Date(segEndMs),
fieldTag,
sleepStage: stage,
// Record-scoped + segment-indexed so a nap's segments never collide
// with the main sleep's and a re-fetch overwrites in place.
externalId: `sleep:${s.id}:${fieldTag}`,
});
segIndex += 1;
}
i += run;
}
return out.length > 0 ? out : null;
}
/**
* Map one Oura sleep record. 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): MappedMeasurement[] {
const measuredAt = dayAnchor(s.day, s.bedtime_end);
if (!measuredAt) return [];
const out: MappedMeasurement[] = [];
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: round2(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag,
sleepStage: stage,
});
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: round2(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",
@@ -343,6 +542,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "%",
measuredAt,
fieldTag: "sleep_eff",
externalId: `sleep:${s.id}:sleep_eff`,
});
}
if (typeof s.average_hrv === "number" && s.average_hrv > 0) {
@@ -352,6 +552,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
externalId: `sleep:${s.id}:hrv_rmssd`,
});
}
if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) {
@@ -361,6 +562,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "bpm",
measuredAt,
fieldTag: "rhr",
externalId: `sleep:${s.id}:rhr`,
});
}
if (typeof s.average_breath === "number" && s.average_breath > 0) {
@@ -370,12 +572,49 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
externalId: `sleep:${s.id}:resp_rate`,
});
}
return out;
}
/** Map one Oura daily-activity record: steps + active energy. */
/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */
export function mapDailySleep(d: OuraDailySleep): MappedMeasurement[] {
if (typeof d.score !== "number") return [];
const measuredAt = dayAnchor(d.day, d.timestamp);
if (!measuredAt) return [];
return [
{
type: "SLEEP_SCORE",
value: round2(d.score),
unit: "score",
measuredAt,
fieldTag: "sleep_score",
},
];
}
/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */
export function mapDailySpo2(s: OuraDailySpo2): MappedMeasurement[] {
const avg = s.spo2_percentage?.average;
if (typeof avg !== "number" || avg <= 0) return [];
// Anchor SpO2 at the day's UTC midnight (no per-record instant in the
// daily_spo2 collection); the recovery / rollup readers key off the local day.
const measuredAt = dayAnchor(s.day);
if (!measuredAt) return [];
return [
{
type: "OXYGEN_SATURATION",
value: round2(avg),
unit: "%",
measuredAt,
fieldTag: "spo2",
},
];
}
/** Map one Oura daily-activity record: steps, active energy, and the
* equivalent walking distance (B5 — the OURA distance ladder slot was dead). */
export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] {
const measuredAt = dayAnchor(a.day, a.timestamp);
if (!measuredAt) return [];
@@ -398,6 +637,20 @@ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] {
fieldTag: "active_energy",
});
}
if (
typeof a.equivalent_walking_distance === "number" &&
a.equivalent_walking_distance >= 0
) {
out.push({
type: "WALKING_RUNNING_DISTANCE",
// Oura reports the equivalent walking distance in metres — the canonical
// WALKING_RUNNING_DISTANCE unit (no conversion).
value: round2(a.equivalent_walking_distance),
unit: "m",
measuredAt,
fieldTag: "distance",
});
}
return out;
}
+5 -9
View File
@@ -19,20 +19,16 @@ import {
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
export type OuraClassification = IntegrationClassification;
export type ClassifiedOuraResponse = ClassifiedHttpResponse;
export function classifyOuraResponse(
httpStatus: number,
): ClassifiedOuraResponse {
): ClassifiedHttpResponse {
return classifyHttpStatus(httpStatus);
}
export class OuraApiError extends IntegrationApiError {
constructor(opts: {
verb: string;
classification: OuraClassification;
classification: IntegrationClassification;
httpStatus: number | undefined;
reason: string;
upstreamError?: string;
@@ -46,13 +42,13 @@ export class OuraApiError extends IntegrationApiError {
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked-refresh-token signal. Lifts a 400 carrying
* `invalid_grant` from `persistent` to `reauth_required` so the connection
* surfaces the reconnect prompt.
* surfaces the reconnect prompt. Internal to `classifyOuraError`.
*/
export function isInvalidGrant(err: unknown): boolean {
function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Oura");
}
export function classifyOuraError(err: unknown): OuraClassification {
export function classifyOuraError(err: unknown): IntegrationClassification {
if (isInvalidGrant(err)) return "reauth_required";
return classifyIntegrationError(err, "Oura");
}
+24 -7
View File
@@ -1,17 +1,22 @@
/**
* v1.17.0 (F4) — Oura Cloud v2 sync.
*
* Pulls daily readiness (→ RECOVERY_SCORE), sleep (per-stage durations,
* efficiency, HRV, RHR, respiratory rate), and daily activity (steps + active
* energy) for one connected user, mapping each into `Measurement` rows tagged
* `source = OURA`.
* Pulls daily readiness (→ RECOVERY_SCORE + BODY_TEMPERATURE_DEVIATION), sleep
* (real per-segment hypnogram timeline when present, else per-stage totals;
* efficiency, HRV, RHR, respiratory rate), daily activity (steps, active
* energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE),
* and daily SpO2 (→ OXYGEN_SATURATION) for one connected user, mapping each into
* `Measurement` rows tagged `source = OURA`.
*
* Token model: Oura uses refresh tokens. The merged schema has no expiry
* column, so the sync refreshes REACTIVELY — the first read that 401s triggers
* one refresh (persisting BOTH rotated tokens) and a single retry. A failed
* refresh (`invalid_grant`) records `reauth_required` on the `oura` ledger.
*
* Idempotency: `externalId = <resource>:<day>:<fieldTag>`, upsert keyed on
* Idempotency: `externalId = <resource>:<day>:<fieldTag>` for the day-keyed
* collections; sleep rows carry a record-scoped `sleep:<record-id>:<fieldTag>`
* key (per-segment timeline + nightly scalars) so a nap and the main sleep on
* one day stay distinct instead of overwriting each other (B2). Upsert keyed on
* `(userId, type, source = OURA, externalId)`. Oura finalises a day's scores
* after the night, so the `update` branch overwrites in place (re-score).
*
@@ -33,9 +38,13 @@ import {
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
fetchDailyActivity,
fetchDailySleep,
fetchDailySpo2,
fetchReadiness,
fetchSleep,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapReadiness,
mapSleep,
refreshAccessToken,
@@ -79,7 +88,11 @@ function toUpsert(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`,
// A mapper that needs a record-scoped key (sleep rows — per-segment timeline
// + nightly scalars) carries its own externalId; everything else falls back
// to the day-keyed `<resource>:<day>:<fieldTag>` shape.
externalId:
m.externalId ?? `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
}));
}
@@ -101,15 +114,19 @@ async function fetchAll(
const query = { startDate: ymd(start), endDate: ymd(now) };
const run = async (token: string): Promise<OuraMeasurementUpsert[]> => {
const [readiness, sleeps, activities] = await Promise.all([
const [readiness, sleeps, activities, dailySleep, spo2] = await Promise.all([
fetchReadiness(token, query),
fetchSleep(token, query),
fetchDailyActivity(token, query),
fetchDailySleep(token, query),
fetchDailySpo2(token, query),
]);
const out: OuraMeasurementUpsert[] = [];
for (const r of readiness) out.push(...toUpsert(mapReadiness(r), "readiness"));
for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep"));
for (const a of activities) out.push(...toUpsert(mapDailyActivity(a), "activity"));
for (const d of dailySleep) out.push(...toUpsert(mapDailySleep(d), "daily_sleep"));
for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2"));
return out;
};
+6
View File
@@ -183,6 +183,12 @@ export function getPRDirection(
// other device-derived scores (HRV_RMSSD / DAY_STRAIN).
case "ANS_CHARGE":
case "CARDIO_LOAD":
// v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite
// (like the other sleep-quality indices), and the body-temperature
// deviation is a homeostatic signed offset centred on 0 — neither has a
// goal axis, so both stay null like the rest of the derived/homeostatic set.
case "SLEEP_SCORE":
case "BODY_TEMPERATURE_DEVIATION":
return null;
}
}
+22
View File
@@ -107,6 +107,15 @@ export const measurementTypeEnum = z.enum([
// from WHOOP DAY_STRAIN and the COMPUTED STRAIN_SCORE).
"ANS_CHARGE",
"CARDIO_LOAD",
// ── v1.17.1 — Oura coverage completion (additive) ──
// Oura's headline 0–100 Sleep Score (`daily_sleep.score`), distinct from the
// WHOOP SLEEP_PERFORMANCE / SLEEP_EFFICIENCY sub-scores. The body-temperature
// deviation is a SIGNED °C offset from the user's baseline
// (`daily_readiness.temperature_deviation`), not an absolute reading, so it
// never shares the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE
// buckets. Both ingest server-side as `source = OURA`.
"SLEEP_SCORE",
"BODY_TEMPERATURE_DEVIATION",
]);
/**
@@ -322,6 +331,11 @@ const unitMap: Record<string, string> = {
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "score",
CARDIO_LOAD: "score",
// ── v1.17.1 — Oura coverage completion ──
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
SLEEP_SCORE: "score",
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
BODY_TEMPERATURE_DEVIATION: "celsius",
};
export function getUnitForType(type: string): string {
@@ -504,6 +518,14 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// ceiling over any plausible single-day training load.
ANS_CHARGE: { min: -100, max: 100 },
CARDIO_LOAD: { min: 0, max: 1000 },
// ── v1.17.1 — Oura coverage completion ──
// Sleep Score (0–100), same band as the other score classes.
SLEEP_SCORE: { min: 0, max: 100 },
// Body-temperature deviation (°C) — a signed nightly offset centred on 0.
// Oura clamps its own display near ±1 °C; ±5 °C is a generous band that
// rejects an obvious sensor glitch while keeping every real illness /
// luteal-phase swing.
BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 },
};
export function validateMeasurementRange(