mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(oura): pull the real sleep timeline, SpO2, Sleep Score and more
Widen the Oura sync from a thin slice to the signals the v2 API actually
exposes, all server-authoritative:
- Real measured sleep timeline. mapSleep now reads the per-stage
sleep_phase_5_min hypnogram and emits one timed SLEEP_DURATION row per
contiguous 5-minute run, each stamped at its own end instant from
bedtime_start. The per-night reader reconstructs the true clock-time
timeline (measured, not synthesised) instead of the degraded
all-on-bedtime_end shape. Falls back to the four stage totals when no
hypnogram is present.
- daily_spo2 → OXYGEN_SATURATION and daily_sleep → SLEEP_SCORE, filling
two ladder slots that nothing wrote before.
- daily_readiness temperature_deviation → BODY_TEMPERATURE_DEVIATION, and
the eight readiness contributors are parsed (no longer dropped at the
JSON boundary) for the Insights surface.
- daily_activity equivalent_walking_distance → WALKING_RUNNING_DISTANCE,
so an Oura-only day no longer contributes zero distance.
Every sleep row now carries a record-scoped externalId
(sleep:<record-id>:<tag>) so a nap and the main sleep on one day stay
distinct instead of overwriting each other on a shared day key.
This commit is contained in:
1 parent
3cdbab908c
commit
c5e91c4cb6
4 files changed
+480
-40
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
+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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user