mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge w11b/ouraresil into release/v1.19.0
This commit is contained in:
commit
2760f6b327
21 files changed
+338
-24
No files matched your search
@@ -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:
|
||||
|
||||
@@ -544,6 +544,7 @@
|
||||
"typeCardioLoad": "Kardio-Belastung",
|
||||
"typeSleepScore": "Schlafscore",
|
||||
"typeBodyTemperatureDeviation": "Körpertemperaturabweichung",
|
||||
"typeResilience": "Resilienz",
|
||||
"sourceComputed": "Berechnet",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
|
||||
@@ -544,6 +544,7 @@
|
||||
"typeCardioLoad": "Cardio load",
|
||||
"typeSleepScore": "Sleep score",
|
||||
"typeBodyTemperatureDeviation": "Body temperature deviation",
|
||||
"typeResilience": "Resilience",
|
||||
"sourceComputed": "Computed",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -544,6 +544,7 @@
|
||||
"typeCardioLoad": "Carico cardio",
|
||||
"typeSleepScore": "Punteggio del sonno",
|
||||
"typeBodyTemperatureDeviation": "Deviazione della temperatura corporea",
|
||||
"typeResilience": "Resilienza",
|
||||
"sourceComputed": "Calcolato",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
|
||||
@@ -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';
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user