From aff4e9abeb45c62ece948bac82e865061ad6348c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 10:44:15 +0200 Subject: [PATCH] feat(oura): capture daily resilience as an ordinal RESILIENCE metric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oura's daily_resilience collection reports a categorical level — limited, adequate, solid, strong, exceptional. Encode it ordinally (limited=1 … exceptional=5) into the numeric measurement value so it fits the existing model with no new categorical column: add the RESILIENCE measurement type (migration 0186), fetch and map daily_resilience in the Oura sync, and skip an unknown or missing level rather than coercing it to zero. RESILIENCE_LEVELS in the Oura client is the single source of truth for the encoding. The day-keyed externalId keeps a re-sync idempotent so a re-scored day overwrites in place. Extend the measurement-type label, unit, range, category, chart-token, and PR-direction maps; add the metric label across all six locales. --- docs/api/openapi.yaml | 8 ++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../0186_v1190_oura_resilience/migration.sql | 25 ++++++ prisma/schema.prisma | 11 +++ .../measurements/measurement-list-meta.ts | 10 +++ .../measurement-type-enum-coverage.test.ts | 10 ++- src/lib/doctor-report-pdf-core.ts | 4 + src/lib/insights/chart-tokens.ts | 3 + .../__tests__/apple-health-mapping.test.ts | 4 + src/lib/measurements/categories.ts | 4 + src/lib/oura/__tests__/client.test.ts | 82 +++++++++++++++++++ src/lib/oura/__tests__/sync.test.ts | 55 +++++++++++++ src/lib/oura/client.ts | 77 +++++++++++++++-- src/lib/oura/sync.ts | 44 ++++++---- src/lib/personal-records/pr-direction.ts | 5 ++ src/lib/validations/measurement.ts | 14 ++++ 21 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/0186_v1190_oura_resilience/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 447a5e656..3dbda66e4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1669,6 +1669,7 @@ paths: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE - in: query name: from schema: @@ -1855,6 +1856,7 @@ paths: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number measuredAt: @@ -4957,6 +4959,7 @@ paths: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE 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." @@ -10528,6 +10531,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number unit: @@ -10841,6 +10845,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE additionalProperties: type: array items: @@ -10917,6 +10922,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number description: Daily mean (spot metrics) or SUM (cumulative). @@ -11014,6 +11020,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE description: Per-type daily series, keyed by MeasurementType. required: - series @@ -11232,6 +11239,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number unit: diff --git a/messages/de.json b/messages/de.json index 31bd8ee23..adcc76420 100644 --- a/messages/de.json +++ b/messages/de.json @@ -544,6 +544,7 @@ "typeCardioLoad": "Kardio-Belastung", "typeSleepScore": "Schlafscore", "typeBodyTemperatureDeviation": "Körpertemperaturabweichung", + "typeResilience": "Resilienz", "sourceComputed": "Berechnet", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", diff --git a/messages/en.json b/messages/en.json index ac76e0b75..162c511e4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -544,6 +544,7 @@ "typeCardioLoad": "Cardio load", "typeSleepScore": "Sleep score", "typeBodyTemperatureDeviation": "Body temperature deviation", + "typeResilience": "Resilience", "sourceComputed": "Computed", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", diff --git a/messages/es.json b/messages/es.json index e325e4055..66dd8df1e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -544,6 +544,7 @@ "typeCardioLoad": "Carga cardio", "typeSleepScore": "Puntuación de sueño", "typeBodyTemperatureDeviation": "Desviación de temperatura corporal", + "typeResilience": "Resiliencia", "sourceComputed": "Calculado", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", diff --git a/messages/fr.json b/messages/fr.json index fa9ffc99e..58cede20d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -544,6 +544,7 @@ "typeCardioLoad": "Charge cardio", "typeSleepScore": "Score de sommeil", "typeBodyTemperatureDeviation": "Écart de température corporelle", + "typeResilience": "Résilience", "sourceComputed": "Calculé", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", diff --git a/messages/it.json b/messages/it.json index 08c442904..973dbe0dc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -544,6 +544,7 @@ "typeCardioLoad": "Carico cardio", "typeSleepScore": "Punteggio del sonno", "typeBodyTemperatureDeviation": "Deviazione della temperatura corporea", + "typeResilience": "Resilienza", "sourceComputed": "Calcolato", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", diff --git a/messages/pl.json b/messages/pl.json index b97894578..edb331905 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -544,6 +544,7 @@ "typeCardioLoad": "Obciążenie kardio", "typeSleepScore": "Wynik snu", "typeBodyTemperatureDeviation": "Odchylenie temperatury ciała", + "typeResilience": "Odporność", "sourceComputed": "Obliczone", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", diff --git a/prisma/migrations/0186_v1190_oura_resilience/migration.sql b/prisma/migrations/0186_v1190_oura_resilience/migration.sql new file mode 100644 index 000000000..2512f01ea --- /dev/null +++ b/prisma/migrations/0186_v1190_oura_resilience/migration.sql @@ -0,0 +1,25 @@ +-- v1.19.0 — Oura resilience capture (additive). +-- +-- One new `measurement_type` enum value, `RESILIENCE`, so the Oura sync can +-- store the daily resilience level from the `daily_resilience` collection. +-- +-- Oura exposes resilience as a categorical band — limited / adequate / solid / +-- strong / exceptional — describing how well the body copes with cumulative +-- load. Rather than introduce a new categorical column + CHECK constraint, the +-- level is ORDINAL-ENCODED into the existing numeric `value`: +-- +-- limited = 1, adequate = 2, solid = 3, strong = 4, exceptional = 5 +-- +-- The 5-level → ordinal mapping is the single source of truth in +-- `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Unit is the level scale +-- (`level`). Stored as `source = OURA`. An unknown / missing level string mints +-- no row (it is skipped, never coerced to 0). +-- +-- Purely-additive: one enum extension, no backfill, no existing row touched, no +-- new table or column. Idempotent guard (`IF NOT EXISTS`) so reruns are safe on +-- prod. +-- +-- Reversibility (down): Postgres cannot drop a single enum value in place; the +-- value is inert until a row references it, so a down migration is a no-op. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESILIENCE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f07426b66..72b0bd59b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -905,6 +905,17 @@ enum MeasurementType { // absolute-reading buckets. Plausible band is small and centred on 0. BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`. + // ── v1.19.0 — Oura resilience (additive) ── + // Oura's daily resilience level from the `daily_resilience` collection — a + // categorical band describing how well the body is coping with cumulative + // load: limited / adequate / solid / strong / exceptional. Stored ORDINAL- + // ENCODED into the numeric `value` (limited=1, adequate=2, solid=3, strong=4, + // exceptional=5) so it fits the existing numeric Measurement model with no new + // categorical column or CHECK constraint. Unit is the level scale (`level`). + // The 5-level → ordinal mapping is the single source of truth in + // `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`. + RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5). + @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 8c39cca9c..f27dd5ef2 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -120,6 +120,8 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { // ── v1.17.1 — Oura coverage completion ── SLEEP_SCORE: "measurements.typeSleepScore", BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation", + // ── v1.19.0 — Oura resilience ── + RESILIENCE: "measurements.typeResilience", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -239,6 +241,10 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // ── v1.17.1 — Oura coverage completion ── SLEEP_SCORE: Moon, BODY_TEMPERATURE_DEVIATION: Thermometer, + // ── v1.19.0 — Oura resilience ── + // Gauge reads as the composite "index / level" dial, matching the other + // recovery / score composites (RECOVERY_SCORE, DAY_STRAIN). + RESILIENCE: Gauge, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -349,4 +355,8 @@ export const MEASUREMENT_TYPE_COLORS: Record = { // for the body-temperature deviation. SLEEP_SCORE: "bg-chart-2/20 text-chart-2", BODY_TEMPERATURE_DEVIATION: "bg-chart-5/20 text-chart-5", + // ── v1.19.0 — Oura resilience ── + // chart-1 (Dracula purple) marks the derived recovery / score composites as + // their own visual group, matching RECOVERY_SCORE / DAY_STRAIN. + RESILIENCE: "bg-chart-1/20 text-chart-1", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index d914df361..17b9a1fdf 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -96,10 +96,12 @@ const EXPECTED_TYPES = [ // ── v1.17.1 — Oura coverage completion ── "SLEEP_SCORE", "BODY_TEMPERATURE_DEVIATION", + // ── v1.19.0 — Oura resilience (ordinal-encoded level) ── + "RESILIENCE", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 65 canonical measurement types", () => { + it("exposes the 66 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -236,6 +238,12 @@ describe("measurementTypeEnum coverage", () => { // doctor-report-pdf-core.ts for the matching exclusion rationale. "SLEEP_SCORE", "BODY_TEMPERATURE_DEVIATION", + // v1.19.0 — Oura resilience. The resilience level is an ordinal-encoded + // categorical band (limited=1 … exceptional=5), a derived recovery + // composite, not a measured clinical vital — it surfaces on the + // Insights / measurement-list surfaces and never belongs in the clinical + // vitals PDF. See doctor-report-pdf-core.ts for the matching rationale. + "RESILIENCE", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index ced3263df..79d3ade8b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -201,6 +201,10 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { * 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. + * + * v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical + * band (limited=1 … exceptional=5), a derived recovery composite — descriptive, + * not a measured clinical vital — so it is excluded too. */ export const DOCTOR_REPORT_VITAL_TYPES = [ "WEIGHT", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index aaeccf3a2..fca9edb1f 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -124,6 +124,9 @@ export const ALLOWED_CHART_TOKENS = [ // each carries a `metric:` token and renders through the generic chart. "metric:SLEEP_SCORE", "metric:BODY_TEMPERATURE_DEVIATION", + // v1.19.0 — Oura resilience. A continuous daily ordinal series (1–5), so it + // carries a `metric:` token and renders through the generic chart. + "metric:RESILIENCE", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index f6ebe8a27..0c0a7ef6b 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -73,6 +73,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ // by design. "SLEEP_SCORE", "BODY_TEMPERATURE_DEVIATION", + // v1.19.0 — Oura resilience. The resilience level ingests server-side from + // the Oura API (source = OURA), never from HealthKit: Apple ships no + // resilience metric. No HK mapping by design. + "RESILIENCE", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index d06d157be..f9457adf2 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -200,6 +200,10 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // Body-temperature deviation is an overnight thermal signal that pairs with // skin / wrist temperature in the metabolic cluster. ["BODY_TEMPERATURE_DEVIATION", "metabolic"], + // ── v1.19.0 — Oura resilience ── + // The resilience level is a derived recovery composite (ordinal-encoded + // band) → scores cluster alongside RECOVERY_SCORE / ANS_CHARGE. + ["RESILIENCE", "scores"], ]); /** diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 07409fafd..53e42414f 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OURA_OAUTH_SCOPE, + RESILIENCE_LEVELS, exchangeCode, fetchCardiovascularAge, fetchReadiness, + fetchResilience, fetchVo2Max, getAuthorizationUrl, getOuraCredentials, @@ -13,6 +15,7 @@ import { mapDailySleep, mapDailySpo2, mapReadiness, + mapResilience, mapSleep, mapVo2Max, refreshAccessToken, @@ -21,6 +24,7 @@ import { type OuraDailySleep, type OuraDailySpo2, type OuraReadiness, + type OuraResilience, type OuraSleep, type OuraVo2Max, } from "../client"; @@ -399,6 +403,84 @@ describe("mapCardiovascularAge", () => { }); }); +describe("fetchResilience", () => { + it("reads the daily_resilience collection path", async () => { + const fetchMock = installFetchMock([ + { + status: 200, + body: { + data: [{ id: "1", day: "2026-06-10", level: "solid" }], + next_token: null, + }, + }, + ]); + const r = await fetchResilience("tok", { + startDate: "2026-06-01", + endDate: "2026-06-10", + }); + expect(r).toHaveLength(1); + const [url] = fetchMock.mock.calls[0]!; + expect(url).toContain("/v2/usercollection/daily_resilience"); + }); +}); + +describe("mapResilience", () => { + it("ordinal-encodes all five levels (limited=1 … exceptional=5)", () => { + const expected: Array<[string, number]> = [ + ["limited", 1], + ["adequate", 2], + ["solid", 3], + ["strong", 4], + ["exceptional", 5], + ]; + for (const [level, ordinal] of expected) { + const mapped = mapResilience({ day: "2026-06-10", level }); + expect(mapped).toHaveLength(1); + expect(mapped[0]).toMatchObject({ + type: "RESILIENCE", + value: ordinal, + unit: "level", + fieldTag: "resilience", + }); + } + }); + + it("RESILIENCE_LEVELS is the source of truth for the encoding", () => { + expect(RESILIENCE_LEVELS).toEqual({ + limited: 1, + adequate: 2, + solid: 3, + strong: 4, + exceptional: 5, + }); + }); + + it("is case-insensitive on the level string", () => { + const mapped = mapResilience({ day: "2026-06-10", level: "STRONG" }); + expect(mapped).toHaveLength(1); + expect(mapped[0]).toMatchObject({ type: "RESILIENCE", value: 4 }); + }); + + it("skips an unknown / missing level (no row, never coerced to 0)", () => { + const cases: OuraResilience[] = [ + { day: "2026-06-10", level: "extraordinary" }, + { day: "2026-06-10", level: "" }, + { day: "2026-06-10", level: null }, + { day: "2026-06-10" }, + ]; + for (const c of cases) { + expect(mapResilience(c)).toEqual([]); + } + }); + + it("anchors the row at the day's UTC midnight", () => { + const mapped = mapResilience({ day: "2026-06-10", level: "adequate" }); + expect(mapped[0]?.measuredAt.toISOString()).toBe( + "2026-06-10T00:00:00.000Z", + ); + }); +}); + describe("OURA_OAUTH_SCOPE", () => { it("requests daily + personal", () => { expect(OURA_OAUTH_SCOPE).toBe("daily personal"); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 8fac99762..52cd7742a 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -11,6 +11,7 @@ const { fetchSpo2Mock, fetchVo2MaxMock, fetchCardioAgeMock, + fetchResilienceMock, refreshMock, upsertMock, recordSuccessMock, @@ -28,6 +29,7 @@ const { fetchSpo2Mock: vi.fn(), fetchVo2MaxMock: vi.fn(), fetchCardioAgeMock: vi.fn(), + fetchResilienceMock: vi.fn(), refreshMock: vi.fn(), upsertMock: vi.fn(), recordSuccessMock: vi.fn(), @@ -73,6 +75,7 @@ vi.mock("../client", async (importOriginal) => { fetchDailySpo2: fetchSpo2Mock, fetchVo2Max: fetchVo2MaxMock, fetchCardiovascularAge: fetchCardioAgeMock, + fetchResilience: fetchResilienceMock, refreshAccessToken: refreshMock, }; }); @@ -95,6 +98,7 @@ beforeEach(() => { fetchSpo2Mock.mockReset().mockResolvedValue([]); fetchVo2MaxMock.mockReset().mockResolvedValue([]); fetchCardioAgeMock.mockReset().mockResolvedValue([]); + fetchResilienceMock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); upsertMock.mockReset().mockResolvedValue({}); recordSuccessMock.mockReset().mockResolvedValue(undefined); @@ -238,6 +242,57 @@ describe("syncUserOura", () => { expect(written.some((w) => w.type === "STRESS_SCORE")).toBe(false); }); + it("writes RESILIENCE ordinal-encoded from the daily_resilience collection", async () => { + getConnMock.mockResolvedValue(CONN); + fetchResilienceMock.mockResolvedValue([ + { id: "r", day: "2026-06-10", level: "strong" }, + ]); + const imported = await syncUserOura("u1"); + expect(imported).toBe(1); + const arg = upsertMock.mock.calls[0]![0]; + expect(arg.where.userId_type_source_externalId).toMatchObject({ + type: "RESILIENCE", + source: "OURA", + externalId: "resilience:2026-06-10:resilience", + }); + expect(arg.create.value).toBe(4); + expect(arg.create.unit).toBe("level"); + }); + + it("skips an unrecognised resilience level (no row)", async () => { + getConnMock.mockResolvedValue(CONN); + fetchResilienceMock.mockResolvedValue([ + { id: "r", day: "2026-06-10", level: "godlike" }, + ]); + const imported = await syncUserOura("u1"); + expect(imported).toBe(0); + expect(upsertMock).not.toHaveBeenCalled(); + }); + + it("re-sync of the same day upserts RESILIENCE in place (overwrite, not duplicate)", async () => { + getConnMock.mockResolvedValue(CONN); + // Oura finalises a day's level after the night; a re-fetch reports a + // different level on the SAME day → the day-keyed externalId stays stable + // so the upsert update branch overwrites in place. + fetchResilienceMock + .mockResolvedValueOnce([ + { id: "r", day: "2026-06-10", level: "adequate" }, + ]) + .mockResolvedValueOnce([{ id: "r", day: "2026-06-10", level: "solid" }]); + await syncUserOura("u1"); + await syncUserOura("u1"); + const keys = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.externalId, + ); + expect(keys).toEqual([ + "resilience:2026-06-10:resilience", + "resilience:2026-06-10:resilience", + ]); + // The second sync carries the re-scored value through the update branch. + expect(upsertMock.mock.calls[0]![0].create.value).toBe(2); + expect(upsertMock.mock.calls[1]![0].update.value).toBe(3); + }); + it("does NOT refresh on a 403 (not an expiry case)", async () => { getConnMock.mockResolvedValue(CONN); fetchReadinessMock.mockRejectedValue( diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index aebebbbb0..08c045cf0 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -239,6 +239,22 @@ export interface OuraCardiovascularAge { vascular_age?: number | null; } +/** + * Oura `daily_resilience` — the daily resilience LEVEL, a categorical band + * describing how well the body copes with cumulative load. The headline is the + * `level` string (limited / adequate / solid / strong / exceptional); the + * collection also carries `contributors` (sleep_recovery / daytime_recovery / + * stress) which we do NOT ingest — we capture only the headline level. + * Re-verify the `level` field name + value set against + * `cloud.ouraring.com/v2/docs` at build time. + */ +export interface OuraResilience { + id?: string; + day: string; + /** limited | adequate | solid | strong | exceptional */ + level?: string | null; +} + export interface OuraDailyActivity { id: string; day: string; @@ -400,13 +416,17 @@ export function fetchCardiovascularAge( ); } -// Oura `daily_resilience` → no fitting enum. Resilience is a categorical level -// (limited / adequate / solid / strong / exceptional), not a numeric metric; -// HealthLog has no categorical enum + column for it (RECOVERY_SCORE / ANS_CHARGE -// are numeric scores, not a resilience band). Capturing it faithfully would -// need a new MeasurementType plus a categorical context column + CHECK -// constraint (a migration). Deferred until a migration lands — see the v1.18.11 -// backlog report. +export function fetchResilience( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/daily_resilience", + accessToken, + "fetchResilience", + query, + ); +} // ─── Field → Measurement mapping ─────────────────────────────── @@ -756,3 +776,46 @@ export function mapCardiovascularAge( }, ]; } + +/** + * Single source of truth for the Oura resilience level → ordinal encoding. + * Oura's `daily_resilience.level` is a categorical band; we store it ORDINAL- + * ENCODED in the numeric Measurement `value` so it fits the existing model with + * no new categorical column. Keep in lock-step with the schema comment on the + * `RESILIENCE` enum value and migration 0186. + */ +export const RESILIENCE_LEVELS: Record = { + limited: 1, + adequate: 2, + solid: 3, + strong: 4, + exceptional: 5, +}; + +/** The unit recorded for a RESILIENCE row — the ordinal level scale (1–5). */ +export const RESILIENCE_UNIT = "level" as const; + +/** + * Map one Oura `daily_resilience` record → `RESILIENCE`, ordinal-encoded + * (limited=1 … exceptional=5) into the numeric `value`. An unknown / missing + * level string mints NO row (skipped, never coerced to 0) so a future Oura band + * we do not recognise never lands as a misleading reading. Anchored at the + * day's UTC midnight (the collection carries no per-record instant). + */ +export function mapResilience(r: OuraResilience): MappedMeasurement[] { + const level = typeof r.level === "string" ? r.level.toLowerCase() : null; + if (!level) return []; + const ordinal = RESILIENCE_LEVELS[level]; + if (typeof ordinal !== "number") return []; + const measuredAt = dayAnchor(r.day); + if (!measuredAt) return []; + return [ + { + type: "RESILIENCE", + value: ordinal, + unit: RESILIENCE_UNIT, + measuredAt, + fieldTag: "resilience", + }, + ]; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 63df960f6..cefddf77b 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -14,11 +14,10 @@ * ladder or the weekly graded-series collapse, so a second producer here would * double-count nondeterministically. Re-add once STRESS_SCORE is laddered. * - * daily_resilience is deferred: resilience is a categorical level (limited / - * adequate / solid / strong / exceptional), not a numeric metric, and HealthLog - * has no categorical enum + column for it. Capturing it faithfully needs a new - * MeasurementType plus a categorical context column + CHECK constraint (a - * migration) — out of scope for this migration-free release. + * daily_resilience → RESILIENCE (v1.19.0): the daily resilience LEVEL (limited / + * adequate / solid / strong / exceptional) is ordinal-encoded (limited=1 … + * exceptional=5) into the numeric `value` — no new categorical column. An + * unknown / missing level mints no row. See `RESILIENCE_LEVELS` in `./client`. * * Token model: Oura uses refresh tokens. The merged schema has no expiry * column, so the sync refreshes REACTIVELY — the first read that 401s triggers @@ -54,6 +53,7 @@ import { fetchDailySleep, fetchDailySpo2, fetchReadiness, + fetchResilience, fetchSleep, fetchVo2Max, mapCardiovascularAge, @@ -61,6 +61,7 @@ import { mapDailySleep, mapDailySpo2, mapReadiness, + mapResilience, mapSleep, mapVo2Max, refreshAccessToken, @@ -114,7 +115,7 @@ function toUpsert( } /** - * Fetch all three Oura collections for a user with a single reactive + * Fetch every Oura daily collection for a user with a single reactive * refresh-on-401 retry. Returns the raw mapped readings (not yet upserted). * Throws a classified `OuraApiError` on a hard failure so the caller records * the ledger entry. @@ -130,16 +131,25 @@ async function fetchAll( const query = { startDate: ymd(start), endDate: ymd(now) }; const run = async (token: string): Promise => { - const [readiness, sleeps, activities, dailySleep, spo2, vo2max, cardioAge] = - await Promise.all([ - fetchReadiness(token, query), - fetchSleep(token, query), - fetchDailyActivity(token, query), - fetchDailySleep(token, query), - fetchDailySpo2(token, query), - fetchVo2Max(token, query), - fetchCardiovascularAge(token, query), - ]); + const [ + readiness, + sleeps, + activities, + dailySleep, + spo2, + vo2max, + cardioAge, + resilience, + ] = await Promise.all([ + fetchReadiness(token, query), + fetchSleep(token, query), + fetchDailyActivity(token, query), + fetchDailySleep(token, query), + fetchDailySpo2(token, query), + fetchVo2Max(token, query), + fetchCardiovascularAge(token, query), + fetchResilience(token, query), + ]); const out: OuraMeasurementUpsert[] = []; for (const r of readiness) out.push(...toUpsert(mapReadiness(r), "readiness")); @@ -152,6 +162,8 @@ async function fetchAll( for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max")); for (const c of cardioAge) out.push(...toUpsert(mapCardiovascularAge(c), "cardio_age")); + for (const r of resilience) + out.push(...toUpsert(mapResilience(r), "resilience")); return out; }; diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index b866469e5..0058a5131 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -189,6 +189,11 @@ export function getPRDirection( // goal axis, so both stay null like the rest of the derived/homeostatic set. case "SLEEP_SCORE": case "BODY_TEMPERATURE_DEVIATION": + // v1.19.0 — Oura resilience. An ordinal-encoded categorical band + // (limited=1 … exceptional=5) recomputed daily from recovery / stress + // inputs — a derived composite with no clean goal axis, so it stays null + // like the other device-derived scores. + case "RESILIENCE": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index bafff6062..9d3197295 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -116,6 +116,12 @@ export const measurementTypeEnum = z.enum([ // buckets. Both ingest server-side as `source = OURA`. "SLEEP_SCORE", "BODY_TEMPERATURE_DEVIATION", + // ── v1.19.0 — Oura resilience (additive) ── + // Oura's daily resilience LEVEL (`daily_resilience.level`) — a categorical + // band (limited / adequate / solid / strong / exceptional) ORDINAL-ENCODED + // into the numeric value (limited=1 … exceptional=5), unit `level`. Ingests + // server-side as `source = OURA`. See `RESILIENCE_LEVELS` in `src/lib/oura/client`. + "RESILIENCE", ]); /** @@ -333,6 +339,10 @@ const unitMap: Record = { SLEEP_SCORE: "score", // Signed body-temperature deviation in °C (Oura nightly baseline offset). BODY_TEMPERATURE_DEVIATION: "celsius", + // ── v1.19.0 — Oura resilience ── + // Ordinal level scale (1=limited … 5=exceptional) — the categorical band + // encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client. + RESILIENCE: "level", }; export function getUnitForType(type: string): string { @@ -523,6 +533,10 @@ export const VALUE_RANGES: Record = { // rejects an obvious sensor glitch while keeping every real illness / // luteal-phase swing. BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 }, + // ── v1.19.0 — Oura resilience ── + // Ordinal level scale: 1 (limited) … 5 (exceptional). The closed encoding + // never falls outside this band. + RESILIENCE: { min: 1, max: 5 }, }; export function validateMeasurementRange(