Merge w11b/ouraresil into release/v1.19.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 12:22:21 +02:00
commit 2760f6b327
21 files changed
+338 -24

No files matched your search

+8
View File
@@ -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:
+1
View File
@@ -544,6 +544,7 @@
"typeCardioLoad": "Kardio-Belastung",
"typeSleepScore": "Schlafscore",
"typeBodyTemperatureDeviation": "Körpertemperaturabweichung",
"typeResilience": "Resilienz",
"sourceComputed": "Berechnet",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
+1
View File
@@ -544,6 +544,7 @@
"typeCardioLoad": "Cardio load",
"typeSleepScore": "Sleep score",
"typeBodyTemperatureDeviation": "Body temperature deviation",
"typeResilience": "Resilience",
"sourceComputed": "Computed",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -544,6 +544,7 @@
"typeCardioLoad": "Carico cardio",
"typeSleepScore": "Punteggio del sonno",
"typeBodyTemperatureDeviation": "Deviazione della temperatura corporea",
"typeResilience": "Resilienza",
"sourceComputed": "Calcolato",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
+1
View File
@@ -544,6 +544,7 @@
"typeCardioLoad": "Obciążenie kardio",
"typeSleepScore": "Wynik snu",
"typeBodyTemperatureDeviation": "Odchylenie temperatury ciała",
"typeResilience": "Odporność",
"sourceComputed": "Obliczone",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
@@ -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';
+11
View File
@@ -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")
}
@@ -120,6 +120,8 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
// ── 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<string, LucideIcon> = {
@@ -239,6 +241,10 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// ── 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<string, string> = {
@@ -349,4 +355,8 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
// 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",
};
@@ -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", () => {
+4
View File
@@ -201,6 +201,10 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* 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",
+3
View File
@@ -124,6 +124,9 @@ export const ALLOWED_CHART_TOKENS = [
// each carries a `metric:<TYPE>` 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:<TYPE>` token and renders through the generic chart.
"metric:RESILIENCE",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -73,6 +73,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// 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", () => {
+4
View File
@@ -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"],
]);
/**
+82
View File
@@ -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");
+55
View File
@@ -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(
+70 -7
View File
@@ -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<OuraResilience[]> {
return fetchCollection<OuraResilience>(
"/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<string, number> = {
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",
},
];
}
+28 -16
View File
@@ -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<OuraMeasurementUpsert[]> => {
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;
};
+5
View File
@@ -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;
}
}
+14
View File
@@ -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<string, string> = {
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<string, { min: number; max: number }> = {
// 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(