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:
Marc-André Bombeck committed 2026-06-15 00:09:45 +02:00
1 parent 3cdbab908c
commit c5e91c4cb6
4 files changed
+480 -40

No files matched your search

+125 -6
View File
@@ -7,10 +7,14 @@ import {
getAuthorizationUrl,
getOuraCredentials,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapReadiness,
mapSleep,
refreshAccessToken,
type OuraDailyActivity,
type OuraDailySleep,
type OuraDailySpo2,
type OuraReadiness,
type OuraSleep,
} from "../client";
@@ -122,18 +126,41 @@ describe("mapReadiness", () => {
it("maps score -> RECOVERY_SCORE", () => {
const r: OuraReadiness = { id: "1", day: "2026-06-10", score: 84 };
const mapped = mapReadiness(r);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({ type: "RECOVERY_SCORE", value: 84, fieldTag: "recovery" });
expect(mapped.find((m) => m.type === "RECOVERY_SCORE")).toMatchObject({
value: 84,
fieldTag: "recovery",
});
});
it("skips a record with no score", () => {
it("maps temperature_deviation -> BODY_TEMPERATURE_DEVIATION (signed °C)", () => {
const r: OuraReadiness = {
id: "1",
day: "2026-06-10",
score: 70,
temperature_deviation: -0.42,
};
const mapped = mapReadiness(r);
const dev = mapped.find((m) => m.type === "BODY_TEMPERATURE_DEVIATION");
expect(dev).toMatchObject({ value: -0.42, unit: "celsius", fieldTag: "temp_deviation" });
});
it("emits the temperature deviation even when the score is absent", () => {
const mapped = mapReadiness({
id: "1",
day: "2026-06-10",
score: null,
temperature_deviation: 0.3,
});
expect(mapped).toHaveLength(1);
expect(mapped[0].type).toBe("BODY_TEMPERATURE_DEVIATION");
});
it("skips a record with no score and no temperature", () => {
expect(mapReadiness({ id: "1", day: "2026-06-10", score: null })).toEqual([]);
});
});
describe("mapSleep", () => {
it("maps stages s->min, efficiency, hrv, rhr, breath", () => {
it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => {
const s: OuraSleep = {
id: "1",
id: "rec-9",
day: "2026-06-10",
light_sleep_duration: 3600,
deep_sleep_duration: 1800,
@@ -151,22 +178,114 @@ describe("mapSleep", () => {
expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(48);
expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(49);
expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.5);
// B2 — every sleep row is record-scoped so a nap never overwrites the main.
expect(
mapped.every((m) => m.externalId?.startsWith("sleep:rec-9:")),
).toBe(true);
});
it("emits a real timed timeline from sleep_phase_5_min (reconstructed=false)", () => {
// 2× deep (1), 3× light (2), 1× rem (3), 1× awake (4) = 7 five-min intervals.
const s: OuraSleep = {
id: "rec-T",
day: "2026-06-10",
bedtime_start: "2026-06-09T23:00:00.000Z",
bedtime_end: "2026-06-10T07:00:00.000Z",
sleep_phase_5_min: "1122234",
};
const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION");
// Four contiguous runs → four segments, in onset order.
expect(mapped.map((m) => m.sleepStage)).toEqual([
"DEEP",
"CORE",
"REM",
"AWAKE",
]);
// Run lengths × 5 min: 2→10, 3→15, 1→5, 1→5.
expect(mapped.map((m) => m.value)).toEqual([10, 15, 5, 5]);
// Each segment stamped at its own END instant relative to bedtime_start.
const onset = new Date("2026-06-09T23:00:00.000Z").getTime();
expect(mapped[0].measuredAt.getTime()).toBe(onset + 10 * 60_000);
expect(mapped[1].measuredAt.getTime()).toBe(onset + 25 * 60_000);
// Distinct, record-scoped, segment-indexed externalIds (no collapse).
const ids = mapped.map((m) => m.externalId);
expect(new Set(ids).size).toBe(ids.length);
expect(ids[0]).toBe("sleep:rec-T:seg:0");
});
it("ignores an empty / malformed hypnogram and uses stage totals", () => {
const s: OuraSleep = {
id: "rec-E",
day: "2026-06-10",
bedtime_start: "2026-06-09T23:00:00.000Z",
sleep_phase_5_min: "",
deep_sleep_duration: 1800,
};
const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION");
expect(mapped).toHaveLength(1);
expect(mapped[0].sleepStage).toBe("DEEP");
expect(mapped[0].fieldTag).toBe("sleep_deep");
});
});
describe("mapDailyActivity", () => {
it("maps steps and active energy", () => {
it("maps steps, active energy, and equivalent walking distance (m)", () => {
const a: OuraDailyActivity = {
id: "1",
day: "2026-06-10",
steps: 9001,
active_calories: 412,
equivalent_walking_distance: 6543,
};
const mapped = mapDailyActivity(a);
expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(9001);
expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe(
412,
);
const dist = mapped.find((m) => m.type === "WALKING_RUNNING_DISTANCE");
expect(dist).toMatchObject({ value: 6543, unit: "m", fieldTag: "distance" });
});
});
describe("mapDailySleep", () => {
it("maps the Sleep Score -> SLEEP_SCORE", () => {
const d: OuraDailySleep = { id: "1", day: "2026-06-10", score: 82 };
const mapped = mapDailySleep(d);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({
type: "SLEEP_SCORE",
value: 82,
unit: "score",
fieldTag: "sleep_score",
});
});
it("skips a record with no score", () => {
expect(mapDailySleep({ id: "1", day: "2026-06-10", score: null })).toEqual(
[],
);
});
});
describe("mapDailySpo2", () => {
it("maps the average SpO2 -> OXYGEN_SATURATION", () => {
const s: OuraDailySpo2 = {
id: "1",
day: "2026-06-10",
spo2_percentage: { average: 96.8 },
};
const mapped = mapDailySpo2(s);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({
type: "OXYGEN_SATURATION",
value: 96.8,
unit: "%",
fieldTag: "spo2",
});
});
it("skips a record with no average", () => {
expect(
mapDailySpo2({ id: "1", day: "2026-06-10", spo2_percentage: { average: null } }),
).toEqual([]);
});
});
+51
View File
@@ -7,6 +7,8 @@ const {
fetchReadinessMock,
fetchSleepMock,
fetchActivityMock,
fetchDailySleepMock,
fetchSpo2Mock,
refreshMock,
upsertMock,
recordSuccessMock,
@@ -20,6 +22,8 @@ const {
fetchReadinessMock: vi.fn(),
fetchSleepMock: vi.fn(),
fetchActivityMock: vi.fn(),
fetchDailySleepMock: vi.fn(),
fetchSpo2Mock: vi.fn(),
refreshMock: vi.fn(),
upsertMock: vi.fn(),
recordSuccessMock: vi.fn(),
@@ -61,6 +65,8 @@ vi.mock("../client", async (importOriginal) => {
fetchReadiness: fetchReadinessMock,
fetchSleep: fetchSleepMock,
fetchDailyActivity: fetchActivityMock,
fetchDailySleep: fetchDailySleepMock,
fetchDailySpo2: fetchSpo2Mock,
refreshAccessToken: refreshMock,
};
});
@@ -79,6 +85,8 @@ beforeEach(() => {
fetchReadinessMock.mockReset().mockResolvedValue([]);
fetchSleepMock.mockReset().mockResolvedValue([]);
fetchActivityMock.mockReset().mockResolvedValue([]);
fetchDailySleepMock.mockReset().mockResolvedValue([]);
fetchSpo2Mock.mockReset().mockResolvedValue([]);
refreshMock.mockReset();
upsertMock.mockReset().mockResolvedValue({});
recordSuccessMock.mockReset().mockResolvedValue(undefined);
@@ -159,6 +167,49 @@ describe("syncUserOura", () => {
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("keeps a nap and the main sleep distinct via record-scoped externalIds (B2)", async () => {
getConnMock.mockResolvedValue(CONN);
// Two sleep records on the same day: a main sleep + a nap.
fetchSleepMock.mockResolvedValue([
{ id: "main", day: "2026-06-10", deep_sleep_duration: 3600 },
{ id: "nap", day: "2026-06-10", deep_sleep_duration: 1200 },
]);
await syncUserOura("u1");
const externalIds = upsertMock.mock.calls.map(
(c) => c[0].where.userId_type_source_externalId.externalId,
);
expect(externalIds).toContain("sleep:main:sleep_deep");
expect(externalIds).toContain("sleep:nap:sleep_deep");
// The legacy day-keyed collapse would have produced one shared key.
expect(new Set(externalIds).size).toBe(externalIds.length);
});
it("writes the Sleep Score and SpO2 from the new collections", async () => {
getConnMock.mockResolvedValue(CONN);
fetchDailySleepMock.mockResolvedValue([
{ id: "s", day: "2026-06-10", score: 80 },
]);
fetchSpo2Mock.mockResolvedValue([
{ id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } },
]);
await syncUserOura("u1");
const written = upsertMock.mock.calls.map((c) => ({
type: c[0].where.userId_type_source_externalId.type,
externalId: c[0].where.userId_type_source_externalId.externalId,
value: c[0].create.value,
}));
expect(written).toContainEqual({
type: "SLEEP_SCORE",
externalId: "daily_sleep:2026-06-10:sleep_score",
value: 80,
});
expect(written).toContainEqual({
type: "OXYGEN_SATURATION",
externalId: "spo2:2026-06-10:spo2",
value: 97,
});
});
it("does NOT refresh on a 403 (not an expiry case)", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockRejectedValue(
+280 -27
View File
@@ -26,7 +26,12 @@ export interface OuraCredentials {
clientSecret: string;
}
/** Env-configured shared OAuth credentials (F4 = full OAuth from env). */
/**
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
* has not registered their own BYO Oura client id/secret. As of v1.17.1 the
* per-user resolver `getOuraClientCredentials` (DB-first) is the primary path
* and calls this only when no per-user pair is stored.
*/
export function getOuraCredentials(): OuraCredentials | null {
const clientId = process.env.OURA_CLIENT_ID;
const clientSecret = process.env.OURA_CLIENT_SECRET;
@@ -143,6 +148,9 @@ export interface OuraCollection<T> {
export interface OuraSleep {
id: string;
day: string;
/** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */
type?: string | null;
bedtime_start?: string;
bedtime_end?: string;
total_sleep_duration?: number | null;
time_in_bed?: number | null;
@@ -155,6 +163,33 @@ export interface OuraSleep {
lowest_heart_rate?: number | null;
average_hrv?: number | null;
average_breath?: number | null;
/**
* 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`.
* Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem,
* `4`=awake. Re-verify the digit→stage mapping against
* `cloud.ouraring.com/v2/docs` at build time (the encoding is published in
* the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api`
* client libraries). Unlike WHOOP — which exposes only stage totals and must
* synthesise an order — Oura gives the real onsets, so we emit timed
* per-segment rows (`reconstructed: false`).
*/
sleep_phase_5_min?: string | null;
}
/**
* Oura `daily_readiness` contributors — each a 1–100 sub-score that explains
* the headline readiness number. All optional (a record may predate a
* contributor or carry a null for a missing input).
*/
export interface OuraReadinessContributors {
activity_balance?: number | null;
body_temperature?: number | null;
hrv_balance?: number | null;
previous_day_activity?: number | null;
previous_night?: number | null;
recovery_index?: number | null;
resting_heart_rate?: number | null;
sleep_balance?: number | null;
}
export interface OuraReadiness {
@@ -162,6 +197,26 @@ export interface OuraReadiness {
day: string;
timestamp?: string;
score?: number | null;
contributors?: OuraReadinessContributors | null;
/** Nightly body-temperature deviation from the personal baseline, °C (signed). */
temperature_deviation?: number | null;
/** Long-term body-temperature trend deviation, °C (signed). */
temperature_trend_deviation?: number | null;
}
/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */
export interface OuraDailySleep {
id: string;
day: string;
timestamp?: string;
score?: number | null;
}
/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */
export interface OuraDailySpo2 {
id: string;
day: string;
spo2_percentage?: { average?: number | null } | null;
}
export interface OuraDailyActivity {
@@ -171,6 +226,8 @@ export interface OuraDailyActivity {
steps?: number | null;
active_calories?: number | null;
total_calories?: number | null;
/** Equivalent walking distance for the day, metres. */
equivalent_walking_distance?: number | null;
}
interface DateRangeQuery {
@@ -269,6 +326,30 @@ export function fetchDailyActivity(
);
}
export function fetchDailySleep(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraDailySleep[]> {
return fetchCollection<OuraDailySleep>(
"/v2/usercollection/daily_sleep",
accessToken,
"fetchDailySleep",
query,
);
}
export function fetchDailySpo2(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraDailySpo2[]> {
return fetchCollection<OuraDailySpo2>(
"/v2/usercollection/daily_spo2",
accessToken,
"fetchDailySpo2",
query,
);
}
// ─── Field → Measurement mapping ───────────────────────────────
const SEC_TO_MIN = 1 / 60;
@@ -284,6 +365,15 @@ export interface MappedMeasurement {
measuredAt: Date;
fieldTag: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE";
/**
* Per-row externalId override. Most rows derive their externalId from the
* resource prefix + day + fieldTag in the sync layer, but the per-segment
* sleep-timeline rows (and any other row that needs a record-scoped key)
* carry their own here so several rows of one night stay distinct under
* `userId_type_source_externalId`. The sync layer uses `m.externalId ??`
* the default key.
*/
externalId?: string;
}
function dayAnchor(day: string, bedtimeEnd?: string): Date | null {
@@ -294,48 +384,157 @@ function dayAnchor(day: string, bedtimeEnd?: string): Date | null {
return Number.isNaN(d.getTime()) ? null : d;
}
/** Map one Oura readiness record: `score` → `RECOVERY_SCORE` (source OURA,
* feeding the recovery ladder below WHOOP). */
/**
* Map one Oura readiness record:
* - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder
* below WHOOP).
* - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset
* from the personal baseline — illness / luteal-phase / stress signal).
*
* The eight readiness `contributors` are the *why* behind the score; they are
* carried back to the caller separately (not minted as Measurement rows here
* since each is a sub-score, not a stored metric) — see the dropped-field note.
* `temperature_trend_deviation` is the long-term trend twin; we keep only the
* nightly deviation as the actionable signal.
*/
export function mapReadiness(r: OuraReadiness): MappedMeasurement[] {
if (typeof r.score !== "number") return [];
const measuredAt = dayAnchor(r.day, r.timestamp);
if (!measuredAt) return [];
return [
{
const out: MappedMeasurement[] = [];
if (typeof r.score === "number") {
out.push({
type: "RECOVERY_SCORE",
value: round2(r.score),
unit: "score",
measuredAt,
fieldTag: "recovery",
},
];
});
}
if (typeof r.temperature_deviation === "number") {
out.push({
type: "BODY_TEMPERATURE_DEVIATION",
value: round2(r.temperature_deviation),
unit: "celsius",
measuredAt,
fieldTag: "temp_deviation",
});
}
return out;
}
/** Map one Oura sleep record: per-stage durations (s→min), efficiency, HRV,
* RHR (lowest_heart_rate), respiratory rate. */
/**
* Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per
* 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake.
* Light maps to our CORE stage (the granular-asleep stage the sleep-night
* reader scores), matching the `light_sleep_duration → CORE` convention the
* stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`.
*/
const HYPNOGRAM_STAGE: Record<string, NonNullable<MappedMeasurement["sleepStage"]>> =
{
"1": "DEEP",
"2": "CORE",
"3": "REM",
"4": "AWAKE",
};
/** Each hypnogram digit covers 5 minutes. */
const HYPNOGRAM_INTERVAL_MIN = 5;
const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000;
/**
* Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured
* `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one
* segment; each segment is stamped at its own END instant (start + duration)
* relative to `bedtime_start`, so the sleep-night reader (which derives
* `start = end − value·60_000`) reconstructs the actual clock-time timeline —
* NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed:
* false` by virtue of OURA being absent from the reader's
* `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the
* hypnogram is absent / unusable so the caller falls back to stage totals.
*/
function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null {
const hyp = s.sleep_phase_5_min;
if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) {
return null;
}
const onset = new Date(s.bedtime_start);
if (Number.isNaN(onset.getTime())) return null;
const out: MappedMeasurement[] = [];
let segIndex = 0;
let i = 0;
while (i < hyp.length) {
const digit = hyp[i];
const stage = HYPNOGRAM_STAGE[digit];
// Walk the run of identical digits.
let run = 1;
while (i + run < hyp.length && hyp[i + run] === digit) run += 1;
if (stage) {
const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS;
const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS;
const fieldTag = `seg:${segIndex}`;
out.push({
type: "SLEEP_DURATION",
value: round2(run * HYPNOGRAM_INTERVAL_MIN),
unit: "minutes",
measuredAt: new Date(segEndMs),
fieldTag,
sleepStage: stage,
// Record-scoped + segment-indexed so a nap's segments never collide
// with the main sleep's and a re-fetch overwrites in place.
externalId: `sleep:${s.id}:${fieldTag}`,
});
segIndex += 1;
}
i += run;
}
return out.length > 0 ? out : null;
}
/**
* Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is
* present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured
* timeline); otherwise we fall back to the four per-stage totals stamped on the
* sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and
* respiratory rate.
*/
export function mapSleep(s: OuraSleep): MappedMeasurement[] {
const measuredAt = dayAnchor(s.day, s.bedtime_end);
if (!measuredAt) return [];
const out: MappedMeasurement[] = [];
const stages: Array<[number | null | undefined, MappedMeasurement["sleepStage"], string]> = [
[s.light_sleep_duration, "CORE", "sleep_core"],
[s.deep_sleep_duration, "DEEP", "sleep_deep"],
[s.rem_sleep_duration, "REM", "sleep_rem"],
[s.awake_time, "AWAKE", "sleep_awake"],
];
for (const [sec, stage, fieldTag] of stages) {
if (typeof sec === "number" && sec >= 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag,
sleepStage: stage,
});
const timeline = mapSleepTimeline(s);
if (timeline) {
out.push(...timeline);
} else {
// Fallback: no hypnogram — stamp the four stage totals on the sleep END.
const stages: Array<
[number | null | undefined, MappedMeasurement["sleepStage"], string]
> = [
[s.light_sleep_duration, "CORE", "sleep_core"],
[s.deep_sleep_duration, "DEEP", "sleep_deep"],
[s.rem_sleep_duration, "REM", "sleep_rem"],
[s.awake_time, "AWAKE", "sleep_awake"],
];
for (const [sec, stage, fieldTag] of stages) {
if (typeof sec === "number" && sec >= 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag,
sleepStage: stage,
// Record-scoped so a nap and the main sleep on the same day don't
// collapse onto one key (B2) — the legacy day-keyed key did.
externalId: `sleep:${s.id}:${fieldTag}`,
});
}
}
}
// The nightly scalars are record-scoped too (B2): a nap and the main sleep on
// the same day would otherwise overwrite each other's efficiency / HRV / RHR /
// respiratory rate on a shared day key.
if (typeof s.efficiency === "number") {
out.push({
type: "SLEEP_EFFICIENCY",
@@ -343,6 +542,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "%",
measuredAt,
fieldTag: "sleep_eff",
externalId: `sleep:${s.id}:sleep_eff`,
});
}
if (typeof s.average_hrv === "number" && s.average_hrv > 0) {
@@ -352,6 +552,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
externalId: `sleep:${s.id}:hrv_rmssd`,
});
}
if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) {
@@ -361,6 +562,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "bpm",
measuredAt,
fieldTag: "rhr",
externalId: `sleep:${s.id}:rhr`,
});
}
if (typeof s.average_breath === "number" && s.average_breath > 0) {
@@ -370,12 +572,49 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] {
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
externalId: `sleep:${s.id}:resp_rate`,
});
}
return out;
}
/** Map one Oura daily-activity record: steps + active energy. */
/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */
export function mapDailySleep(d: OuraDailySleep): MappedMeasurement[] {
if (typeof d.score !== "number") return [];
const measuredAt = dayAnchor(d.day, d.timestamp);
if (!measuredAt) return [];
return [
{
type: "SLEEP_SCORE",
value: round2(d.score),
unit: "score",
measuredAt,
fieldTag: "sleep_score",
},
];
}
/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */
export function mapDailySpo2(s: OuraDailySpo2): MappedMeasurement[] {
const avg = s.spo2_percentage?.average;
if (typeof avg !== "number" || avg <= 0) return [];
// Anchor SpO2 at the day's UTC midnight (no per-record instant in the
// daily_spo2 collection); the recovery / rollup readers key off the local day.
const measuredAt = dayAnchor(s.day);
if (!measuredAt) return [];
return [
{
type: "OXYGEN_SATURATION",
value: round2(avg),
unit: "%",
measuredAt,
fieldTag: "spo2",
},
];
}
/** Map one Oura daily-activity record: steps, active energy, and the
* equivalent walking distance (B5 — the OURA distance ladder slot was dead). */
export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] {
const measuredAt = dayAnchor(a.day, a.timestamp);
if (!measuredAt) return [];
@@ -398,6 +637,20 @@ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] {
fieldTag: "active_energy",
});
}
if (
typeof a.equivalent_walking_distance === "number" &&
a.equivalent_walking_distance >= 0
) {
out.push({
type: "WALKING_RUNNING_DISTANCE",
// Oura reports the equivalent walking distance in metres — the canonical
// WALKING_RUNNING_DISTANCE unit (no conversion).
value: round2(a.equivalent_walking_distance),
unit: "m",
measuredAt,
fieldTag: "distance",
});
}
return out;
}
+24 -7
View File
@@ -1,17 +1,22 @@
/**
* v1.17.0 (F4) — Oura Cloud v2 sync.
*
* Pulls daily readiness (→ RECOVERY_SCORE), sleep (per-stage durations,
* efficiency, HRV, RHR, respiratory rate), and daily activity (steps + active
* energy) for one connected user, mapping each into `Measurement` rows tagged
* `source = OURA`.
* Pulls daily readiness (→ RECOVERY_SCORE + BODY_TEMPERATURE_DEVIATION), sleep
* (real per-segment hypnogram timeline when present, else per-stage totals;
* efficiency, HRV, RHR, respiratory rate), daily activity (steps, active
* energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE),
* and daily SpO2 (→ OXYGEN_SATURATION) for one connected user, mapping each into
* `Measurement` rows tagged `source = OURA`.
*
* Token model: Oura uses refresh tokens. The merged schema has no expiry
* column, so the sync refreshes REACTIVELY — the first read that 401s triggers
* one refresh (persisting BOTH rotated tokens) and a single retry. A failed
* refresh (`invalid_grant`) records `reauth_required` on the `oura` ledger.
*
* Idempotency: `externalId = <resource>:<day>:<fieldTag>`, upsert keyed on
* Idempotency: `externalId = <resource>:<day>:<fieldTag>` for the day-keyed
* collections; sleep rows carry a record-scoped `sleep:<record-id>:<fieldTag>`
* key (per-segment timeline + nightly scalars) so a nap and the main sleep on
* one day stay distinct instead of overwriting each other (B2). Upsert keyed on
* `(userId, type, source = OURA, externalId)`. Oura finalises a day's scores
* after the night, so the `update` branch overwrites in place (re-score).
*
@@ -33,9 +38,13 @@ import {
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
fetchDailyActivity,
fetchDailySleep,
fetchDailySpo2,
fetchReadiness,
fetchSleep,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapReadiness,
mapSleep,
refreshAccessToken,
@@ -79,7 +88,11 @@ function toUpsert(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`,
// A mapper that needs a record-scoped key (sleep rows — per-segment timeline
// + nightly scalars) carries its own externalId; everything else falls back
// to the day-keyed `<resource>:<day>:<fieldTag>` shape.
externalId:
m.externalId ?? `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
}));
}
@@ -101,15 +114,19 @@ async function fetchAll(
const query = { startDate: ymd(start), endDate: ymd(now) };
const run = async (token: string): Promise<OuraMeasurementUpsert[]> => {
const [readiness, sleeps, activities] = await Promise.all([
const [readiness, sleeps, activities, dailySleep, spo2] = await Promise.all([
fetchReadiness(token, query),
fetchSleep(token, query),
fetchDailyActivity(token, query),
fetchDailySleep(token, query),
fetchDailySpo2(token, query),
]);
const out: OuraMeasurementUpsert[] = [];
for (const r of readiness) out.push(...toUpsert(mapReadiness(r), "readiness"));
for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep"));
for (const a of activities) out.push(...toUpsert(mapDailyActivity(a), "activity"));
for (const d of dailySleep) out.push(...toUpsert(mapDailySleep(d), "daily_sleep"));
for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2"));
return out;
};