mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
c016ec8aec
commit
44d8181cb4
5 files changed
+270
-6
No files matched your search
+95
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user