diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 626d0e6b9..6b374462f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -64561,7 +64561,8 @@ components: `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to - discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, + discover by inspecting a live payload. `SLEEP_SCORE` returns the `SleepScoreValue` schema, including + `rhythmNights` and the per-sub-score `gap`. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not @@ -88135,6 +88136,99 @@ components: - asOf - series additionalProperties: false + SleepScoreValue: + type: object + properties: + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The composite 0–100 sleep score. + band: + type: string + enum: + - green + - yellow + - red + description: Server's verdict on the score; never re-band it client-side. + night: + type: string + description: Wake day (YYYY-MM-DD, user's zone) of the night scored. + asleepMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Whole minutes asleep that night. + inBedMinutes: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Whole minutes in bed, null when no in-bed signal exists. + subScores: + type: array + items: + type: object + properties: + key: + type: string + enum: + - sufficiency + - efficiency + - consistency + - timing + - composition + description: Sub-score id. + value: + description: 0–100 sub-score; null when the data cannot support it (dropped from the blend, never 0). + type: + - number + - "null" + weight: + type: number + description: Effective weight after redistributing missing sub-scores. + gap: + description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the + window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had + only a daytime nap. Absent otherwise." + type: string + enum: + - few_nights + - no_main_sleep + required: + - key + - value + - weight + additionalProperties: false + description: All five sub-scores, present and dropped. + windowNights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Scorable nights in the window. + rhythmNights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. + A daytime nap is not one; a short night is. + series: + type: array + items: + type: number + description: Score of every scorable night in the window, oldest → newest, capped at 30 points; the last point is `score`. + required: + - score + - band + - night + - asleepMinutes + - inBedMinutes + - subScores + - windowNights + - rhythmNights + - series + additionalProperties: false parameters: AccountSelector: name: X-HealthLog-Account diff --git a/src/__tests__/derived-sleep-value-contract.test.ts b/src/__tests__/derived-sleep-value-contract.test.ts new file mode 100644 index 000000000..f2fa79766 --- /dev/null +++ b/src/__tests__/derived-sleep-value-contract.test.ts @@ -0,0 +1,99 @@ +/** + * The published `SleepScoreValue` schema against the value the sleep score + * engine actually returns, in both shapes a client meets: a scored rhythm, + * and a rhythm withheld for too few nights (where `gap` appears). A field + * added to the engine and not to the schema, or the reverse, fails here + * rather than shipping as a spec that describes a payload nobody sends. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + user: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +import { prisma } from "@/lib/db"; +import { computeSleepScore } from "@/lib/insights/derived/sleep-score"; +import { sleepScoreValue } from "@/lib/openapi/routes/insights/schemas"; +import { openApiComponents } from "@/lib/openapi/routes"; + +const findMany = prisma.measurement.findMany as ReturnType; +const NOW = new Date("2026-10-01T09:00:00Z"); +const PROFILE = { ageYears: 40, sex: "MALE" as const }; + +/** `nights` overnight sessions with stages, the newest ending this morning. */ +function rows(nights: number) { + const out = []; + for (let i = 0; i < nights; i += 1) { + const wake = Date.UTC(2026, 9, 1 - i, 6, 0); + const segs: Array<[string, number]> = [ + ["CORE", 180], + ["DEEP", 90], + ["REM", 120], + ["AWAKE", 20], + ["CORE", 60], + ]; + let end = wake - segs.reduce((s, [, m]) => s + m, 0) * 60_000; + for (const [stage, minutes] of segs) { + end += minutes * 60_000; + out.push({ + value: minutes, + measuredAt: new Date(end), + sleepStage: stage, + source: "OURA", + deviceType: null, + }); + } + } + return out.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); +} + +function expectExactShape(value: unknown) { + const parsed = sleepScoreValue.safeParse(value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(Object.keys(sleepScoreValue.shape).sort()).toEqual( + Object.keys(value as Record).sort(), + ); +} + +beforeEach(() => { + findMany.mockReset(); +}); + +describe("published SleepScoreValue", () => { + it("is registered as a component even though nothing $refs it", () => { + expect(openApiComponents.schemas?.SleepScoreValue).toBe(sleepScoreValue); + }); + + it("matches a scored night field for field", async () => { + findMany.mockResolvedValue(rows(10)); + const derived = await computeSleepScore("u1", PROFILE, { + now: NOW, + tz: "UTC", + priorityJson: null, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + expectExactShape(derived.value); + expect(derived.value.subScores.every((s) => s.gap === undefined)).toBe( + true, + ); + }); + + it("matches a withheld rhythm, gap included", async () => { + findMany.mockResolvedValue(rows(2)); + const derived = await computeSleepScore("u1", PROFILE, { + now: NOW, + tz: "UTC", + priorityJson: null, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + expectExactShape(derived.value); + expect( + derived.value.subScores.find((s) => s.key === "consistency")?.gap, + ).toBe("few_nights"); + }); +}); diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 0ab861c6b..d2bfd1a3b 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -65,7 +65,7 @@ import { encounterPaths } from "./encounters"; import { vaccinationPaths } from "./vaccinations"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; -import { insightsPaths, wellnessScoreValue } from "./insights"; +import { insightsPaths, sleepScoreValue, wellnessScoreValue } from "./insights"; import { integrationPaths } from "./integrations"; import { insightsSignalPaths } from "./insights-signals"; import { labsPaths } from "./labs"; @@ -245,7 +245,7 @@ export const openApiComponents: NonNullable = { // the DTO and reported two fields as missing that were already on the wire. // Publishing the shape a `$ref` cannot reach is exactly what this slot is // for; the `value` description names it as the shape the three score ids - // return. + // return. `SleepScoreValue` rides the same slot for `SLEEP_SCORE`. schemas: { Medication: medicationResource, MedicationDoseHistoryImportFatalReason: @@ -253,6 +253,7 @@ export const openApiComponents: NonNullable = { CoachPrefs: coachPrefsSchema, MoodTagLayout: moodTagLayout, WellnessScoreValue: wellnessScoreValue, + SleepScoreValue: sleepScoreValue, }, // v1.36.0 — the per-request account selector, defined in the sharing route // module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps diff --git a/src/lib/openapi/routes/insights/index.ts b/src/lib/openapi/routes/insights/index.ts index ad2739082..f4ad3bc4b 100644 --- a/src/lib/openapi/routes/insights/index.ts +++ b/src/lib/openapi/routes/insights/index.ts @@ -4,4 +4,4 @@ * Part of the OpenAPI route table; aggregated in `../index.ts`. */ export { insightsPaths } from "./paths"; -export { wellnessScoreValue } from "./schemas"; +export { sleepScoreValue, wellnessScoreValue } from "./schemas"; diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index a07dc42c8..c18ba6769 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -199,7 +199,7 @@ export const derivedMetricQuery = z * * STILL OPAQUE, and known to be: the value shapes of `VITALS_BASELINE` (prose * only, in the `value` description), `FITNESS_AGE`, `VASCULAR_AGE_DELTA`, - * `HRV_BALANCE`, `BMI`, `SLEEP_SCORE`, `READINESS`, `COINCIDENT_DEVIATION`, + * `HRV_BALANCE`, `BMI`, `READINESS`, `COINCIDENT_DEVIATION`, * `TRAJECTORY`, `SAME_TIME_BASELINE`, `SIX_MINUTE_WALK_BAND`, `HEALTH_SCORE` * and the three fixed-type baselines. Each is a TypeScript interface next to * its engine under `src/lib/insights/derived/`; modelling them is a schema per @@ -274,6 +274,76 @@ export const wellnessScoreValue = z }) .meta({ id: "WellnessScoreValue" }); +/** + * The `SLEEP_SCORE` value, published beside `WellnessScoreValue` for the same + * reason (the derived envelope's `value` is an open record). Held to the + * engine's emitted object by `derived-sleep-value-contract.test.ts`. + */ +export const sleepScoreValue = z + .object({ + score: z.number().int().describe("The composite 0–100 sleep score."), + band: z + .enum(["green", "yellow", "red"]) + .describe("Server's verdict on the score; never re-band it client-side."), + night: z + .string() + .describe("Wake day (YYYY-MM-DD, user's zone) of the night scored."), + asleepMinutes: z + .number() + .int() + .describe("Whole minutes asleep that night."), + inBedMinutes: z + .number() + .int() + .nullable() + .describe("Whole minutes in bed, null when no in-bed signal exists."), + subScores: z + .array( + z.object({ + key: z + .enum([ + "sufficiency", + "efficiency", + "consistency", + "timing", + "composition", + ]) + .describe("Sub-score id."), + value: z + .number() + .nullable() + .describe( + "0–100 sub-score; null when the data cannot support it (dropped from the blend, never 0).", + ), + weight: z + .number() + .describe( + "Effective weight after redistributing missing sub-scores.", + ), + gap: z + .enum(["few_nights", "no_main_sleep"]) + .optional() + .describe( + "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had only a daytime nap. Absent otherwise.", + ), + }), + ) + .describe("All five sub-scores, present and dropped."), + windowNights: z.number().int().describe("Scorable nights in the window."), + rhythmNights: z + .number() + .int() + .describe( + "Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. A daytime nap is not one; a short night is.", + ), + series: z + .array(z.number()) + .describe( + `Score of every scorable night in the window, oldest → newest, capped at ${SPARKLINE_MAX_POINTS} points; the last point is \`score\`.`, + ), + }) + .meta({ id: "SleepScoreValue" }); + export const derivedCoverage = z .object({ requiredInputs: z @@ -410,7 +480,7 @@ export const derivedMetricResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", + "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `SLEEP_SCORE` returns the `SleepScoreValue` schema, including `rhythmNights` and the per-sub-score `gap`. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", ), coverage: derivedCoverage, confidence: derivedConfidence