mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
commit
2736e23092
26 files changed
+873
-50
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user