Merge branch 'fix/v1436-oura' into release/v1.43.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-19 20:20:29 +02:00
commit f05b2f71e7
17 files changed
+429 -54

No files matched your search

@@ -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,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { DataSummary } from "@/lib/analytics/trends";
/**
@@ -45,6 +46,12 @@ function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// The recovery page's empty note waits for the connection status; an
// account with no integrations is the settled answer here.
queryClient.setQueryData(queryKeys.integrationsStatus(), {
threshold: 3,
integrations: [],
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
@@ -9,10 +9,10 @@ import type { DataSummary } from "@/lib/analytics/trends";
/**
* The recovery page's empty note says what is missing for THIS account. An
* Oura ring fills the page through its resilience level alone, so a connected
* ring with an empty page is either a grant that leaves out the heart-health
* data the level sits behind, or Oura not having sent a level yet. The
* connection facts come from the server's integrations read; the page only
* picks the sentence.
* ring with an empty page asks for a reconnect only when the server recorded
* that the grant leaves resilience out, and otherwise says only that no
* level has arrived. The connection facts come from the server's
* integrations read; the page only picks the sentence, and waits for it.
*/
vi.mock("next/dynamic", () => ({
@@ -57,14 +57,19 @@ const OURA_SUMMARIES = {
HRV_RMSSD: summary(30),
};
function render(oura: Record<string, unknown> | null) {
function render(
oura: Record<string, unknown> | null,
opts: { statusLoaded?: boolean } = {},
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
queryClient.setQueryData(queryKeys.integrationsStatus(), {
threshold: 3,
integrations: oura ? [{ integration: "oura", ...oura }] : [],
});
if (opts.statusLoaded !== false) {
queryClient.setQueryData(queryKeys.integrationsStatus(), {
threshold: 3,
integrations: oura ? [{ integration: "oura", ...oura }] : [],
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -104,16 +109,34 @@ describe("<RecoverySection> empty note", () => {
expect(html).toContain('href="/settings/integrations"');
});
it("says Oura has sent no level yet when the grant covers it", () => {
it("says only that no level has arrived when no withheld permission is recorded", () => {
const html = render({
connected: true,
health: health([{ item: "spo2", cause: "permission" }]),
});
expect(html).toContain('data-empty-case="oura-no-resilience"');
expect(html).toContain("has not sent a resilience level");
expect(html).toContain("no resilience level has arrived");
// The server cannot prove the grant covers resilience, so the note
// never says it does.
expect(html).not.toContain("permission covers");
expect(html).not.toContain('href="/settings/integrations"');
});
it("stays neutral when the connection carries no health block", () => {
const html = render({ connected: true });
expect(html).toContain('data-empty-case="oura-no-resilience"');
expect(html).not.toContain("permission covers");
});
it("shows no note until the connection status has loaded", () => {
const html = render(
{ connected: true, health: health([]) },
{ statusLoaded: false },
);
expect(html).toContain('data-slot="recovery-loading"');
expect(html).not.toContain("data-empty-case");
});
it("keeps the general note for an account without Oura", () => {
const html = render(null);
expect(html).toContain('data-empty-case="general"');
@@ -49,11 +49,12 @@ interface BlockGroup {
/**
* Why this account's recovery page is empty, from the server's view of its
* Oura connection. An Oura ring feeds this page through its resilience level
* alone, so a connected ring with an empty page has exactly two causes: the
* grant does not cover the heart-health data the level sits behind (the
* server records that per collection and publishes it as an unavailable
* item), or Oura has not sent a level for this account. Everyone else gets
* the general note naming what fills the page.
* alone. When the server records that the grant leaves resilience out, the
* note asks for one reconnect. Otherwise it says only that no level has
* arrived: the server cannot tell "the grant covers it" from "not recorded
* yet" (the scope note is cleared on every reconnect and written only by a
* sync that reached Oura), so the note never claims coverage. Everyone else
* gets the general note naming what fills the page.
*/
type RecoveryEmptyCase = "general" | "oura-permission" | "oura-no-resilience";
@@ -222,7 +223,9 @@ export function RecoverySection() {
);
const emptyCase = recoveryEmptyCase(pickStatus(statuses.data, "oura"));
if (isLoading || summaries == null) {
// The empty note waits for the connection status, so it never reads as
// the general note first and then changes under the reader.
if (isLoading || summaries == null || (!hasAny && statuses.isPending)) {
return (
<div className="space-y-3" data-slot="recovery-loading">
<ChartSkeleton />
@@ -24,6 +24,7 @@ import { mapSleep, type OuraSleep } from "@/lib/oura/client";
import {
computeSleepScore,
reconstructNights,
isDaytimeNap,
rhythmGap,
scoreConsistency,
} from "../sleep-score";
@@ -137,6 +138,54 @@ describe("Sleep Score rhythm on an Oura month", () => {
expect(regular.midpoint).not.toBeNull();
});
it("keeps a short night on the clock: 2.5 hours asleep is still the night", async () => {
// A month of short nights, 01:30 to 04:00 Berlin (23:30 to 02:00 UTC),
// the shape of a newborn's first weeks or of insomnia.
const rows = Array.from({ length: 20 }, (_, i) => {
const d = i + 10;
const bed = new Date(Date.UTC(2026, 8, d, 23, 30 + ((d * 7) % 20)));
return period(`short-${d}`, "sleep", bed, 150);
}).flatMap((p) =>
mapSleep(p, TZ)
.filter((m) => m.type === "SLEEP_DURATION")
.map((m) => ({
value: m.value,
measuredAt: m.measuredAt,
sleepStage: m.sleepStage ?? null,
source: "OURA",
deviceType: null,
})),
);
const nights = reconstructNights(rows as never, TZ);
expect(nights.length).toBe(20);
for (const n of nights) {
expect(n.asleepMinutes).toBeLessThan(180);
expect(n.midpoint).not.toBeNull();
}
findMany.mockResolvedValue(rows);
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.rhythmNights).toBe(20);
const sub = (k: string) => result.value.subScores.find((s) => s.key === k)!;
expect(sub("consistency").value).toBeGreaterThanOrEqual(70);
expect(sub("consistency").gap).toBeUndefined();
expect(sub("timing").value).not.toBeNull();
expect(sub("timing").gap).toBeUndefined();
});
it("tells a nap from a night by length and time of day together", () => {
// Short and in the afternoon: a nap.
expect(isDaytimeNap(60, 14 * 60)).toBe(true);
// Short but at night: the night.
expect(isDaytimeNap(150, 2 * 60)).toBe(false);
expect(isDaytimeNap(150, 22 * 60)).toBe(false);
// A late sleeper's short night, centred at 09:00.
expect(isDaytimeNap(150, 9 * 60)).toBe(false);
// Long in the daytime (after a night shift): a main sleep.
expect(isDaytimeNap(420, 13 * 60)).toBe(false);
});
it("scores the consistency of a steady sleeper, not 0", async () => {
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
+46 -17
View File
@@ -441,20 +441,26 @@ export function reconstructNights(
// 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in
// consistency and timing.
//
// And only when that main session is a main sleep. On a day the ring or
// watch recorded no night (left on the charger, taken off), the day's
// largest session is an afternoon nap, and its 14:00 midpoint sat ten
// hours from every other night: a single such day in a month pushed the
// midpoint spread past the 90-minute full scale and read the whole
// rhythm as Consistency 0. Such a day still counts its minutes; it just
// has no place on the clock.
// And only when that main session is a main sleep, not a daytime nap. On
// a day the ring or watch recorded no night (left on the charger, taken
// off), the day's largest session is an afternoon nap, and its 14:00
// midpoint sat ten hours from every other night: a single such day in a
// month pushed the midpoint spread past the 90-minute full scale and read
// the whole rhythm as Consistency 0. Such a day still counts its minutes;
// it just has no place on the clock. A short session at night is still
// the night (insomnia, a newborn, split sleep), so it keeps its place.
const main = n.mainSession;
const mainMidpoint = main
? minutesOfDay(
new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000),
tz,
)
: null;
const midpoint =
main && main.asleepMinutes >= MAIN_SLEEP_MIN_MINUTES
? minutesOfDay(
new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000),
tz,
)
main &&
mainMidpoint != null &&
!isDaytimeNap(main.asleepMinutes, mainMidpoint)
? mainMidpoint
: null;
return {
night: n.night,
@@ -470,14 +476,37 @@ export function reconstructNights(
}
/**
* The least asleep time a day's main session needs before its midpoint is a
* point on the sleep rhythm (consistency, timing, chronotype, regularity).
* Three hours is where wearables draw the line between a nap and a main
* sleep (Oura types a period `long_sleep` from three hours on), and well
* above any nap, while a short night still clears it.
* Below this much asleep time a session can be a nap. Three hours is where
* wearables draw the line between a nap and a main sleep (Oura types a
* period `long_sleep` from three hours on).
*/
const MAIN_SLEEP_MIN_MINUTES = 180;
/**
* The night window, in local minutes-of-day: a session whose midpoint falls
* from 20:00 to 10:00 is night sleep however short it is. Wide enough for a
* late chronotype (asleep 04:00 to 12:00 has its midpoint at 08:00) and for
* an early one, and clear of the afternoon where naps sit.
*/
const NIGHT_WINDOW_START = 20 * 60;
const NIGHT_WINDOW_END = 10 * 60;
/**
* True for a session that is a nap rather than a night: short (under
* `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night
* is still the night; a long daytime sleep (after a night shift) is still a
* main sleep. Pure.
*/
export function isDaytimeNap(
asleepMinutes: number,
midpointMinutes: number,
): boolean {
if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false;
const atNight =
midpointMinutes >= NIGHT_WINDOW_START || midpointMinutes < NIGHT_WINDOW_END;
return !atNight;
}
function minutesOfDay(d: Date, tz: string): number {
if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes();
const { hour, minute } = wallClockInTz(d, tz);
+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