docs(openapi): publish the sleep score value

The derived envelope types `value` as an open record, so the sleep
score's `rhythmNights` and per-sub-score `gap` were on the wire but in
no schema. `SleepScoreValue` joins `WellnessScoreValue` in the forced
components slot, the envelope's `value` description names it, and a
contract test holds the published field set to the one the engine
emits, for a scored rhythm and for one withheld for too few nights.

Refs #1204
This commit is contained in:
Marc-André Bombeck committed 2026-10-19 18:56:06 +02:00
1 parent c016ec8aec
commit 44d8181cb4
5 files changed
+270 -6

No files matched your search

+95 -1
View File
@@ -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
@@ -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<typeof vi.fn>;
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<string, unknown>).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");
});
});
+3 -2
View File
@@ -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<ZodOpenApiObject["components"]> = {
// 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<ZodOpenApiObject["components"]> = {
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
+1 -1
View File
@@ -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";
+72 -2
View File
@@ -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