fix(illness,sleep): document the sleep average DTO and gate the recovery driver

Add averagePerNight to the SleepRhythmResource OpenAPI schema so the new
average-per-night card's field is part of the iOS-consumed contract (the
endpoint already returned it; the Zod source had not been updated, so the
in-sync check could not catch the omission).

Tag each vital return with whether it deviated in the illness-adverse
direction and tally only adverse returns when naming the recovery driver,
so a neutral move (e.g. weight drift) can no longer be surfaced as the
vital someone recovered ahead of.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 14:23:24 +02:00
1 parent b9f396c72a
commit e2ce064254
5 files changed
+116 -6

No files matched your search

+29
View File
@@ -11860,9 +11860,38 @@ components:
- workNightsCounted
- freeNightsUntilReady
additionalProperties: false
averagePerNight:
type: object
properties:
state:
type: string
enum:
- partial
- ready
averageMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
description: Mean asleep minutes per scorable night (0 while partial).
nightsCounted:
type: integer
minimum: 0
maximum: 9007199254740991
nightsUntilReady:
type: integer
minimum: 0
maximum: 9007199254740991
description: Scorable nights still needed before the average is asserted.
required:
- state
- averageMinutes
- nightsCounted
- nightsUntilReady
additionalProperties: false
required:
- sleepDebt
- chronotype
- averagePerNight
additionalProperties: false
description: "Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype
(MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work
+6 -4
View File
@@ -138,13 +138,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
derived.status === "ok"
? derived.value.adverseCoverageDays
: 0,
// The vitals that produced a real physiological return —
// tallied across the qualifying episodes to name the dominant
// driving vital in the summary copy.
// The vitals that produced a real physiological return in the
// illness-ADVERSE direction — tallied across the qualifying
// episodes to name the dominant driving vital in the summary
// copy. A neutral move (e.g. weight drift with no adverse
// signal) shows a return but must never be named the driver.
gapReturnTypes:
derived.status === "ok"
? derived.value.returns
.filter((r) => r.gapDays !== null)
.filter((r) => r.gapDays !== null && r.adverse)
.map((r) => String(r.type))
: [],
},
@@ -226,6 +226,54 @@ describe("computeIllnessCorrelation — golden recovery-gap", () => {
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.returns[0]?.returnedDay).toBeNull();
});
it("flags a return adverse only when the vital deviated in the adverse direction", () => {
// A neutral-direction vital (WEIGHT) can deviate out of band and stably
// return — it produces a return finding, but `adverse` must stay false so
// it never gets named the recovery driver. An up-adverse vital (RHR) that
// spikes and returns must read adverse: true.
const weight: MeasurementType = "WEIGHT";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{
type: weight,
baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 90 }, // far out of band, neutral
{ day: "2026-01-11", mean: 91 },
{ day: "2026-01-15", mean: 80 }, // back in band
{ day: "2026-01-16", mean: 80 },
{ day: "2026-01-17", mean: 80 },
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 75 }, // up = adverse
{ day: "2026-01-11", mean: 74 },
{ day: "2026-01-15", mean: 55 }, // back in band
{ day: "2026-01-16", mean: 55 },
{ day: "2026-01-17", mean: 55 },
],
},
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const weightReturn = out.value.returns.find((r) => r.type === weight);
const rhrReturn = out.value.returns.find((r) => r.type === rhr);
expect(weightReturn?.adverse).toBe(false);
expect(rhrReturn?.adverse).toBe(true);
});
});
describe("computeIllnessCorrelation — baseline-contamination guard", () => {
+17 -2
View File
@@ -206,6 +206,10 @@ export interface VitalReturnFinding {
returnedDay: string | null;
/** Days from felt-better to physiological return (signed); null if N/A. */
gapDays: number | null;
/** Whether this vital deviated in the illness-adverse direction during the
* active span — a neutral move (e.g. weight drift with no adverse signal)
* still produces a return but must not name the recovery driver. */
adverse: boolean;
}
/**
@@ -421,11 +425,17 @@ export function computeIllnessCorrelation(
// anchor the recovery search must start AFTER (an early in-band run before
// the vital ever deviated is not a "return", it is the run-up).
let firstDeviationIndex = -1;
// Whether THIS vital ever deviated adversely in the active span — gates it
// out of the recovery-driver tally even if it shows a return.
let vitalDeviatedAdversely = false;
active.forEach((p, i) => {
const f = finding(type, p.day, p.mean, center, spread);
if (Math.abs(f.deviationSd) >= NOTABLE_SD) {
if (firstDeviationIndex === -1) firstDeviationIndex = i;
if (f.adverse) adverseDayKeys.add(p.day);
if (f.adverse) {
adverseDayKeys.add(p.day);
vitalDeviatedAdversely = true;
}
if (
worstActive === null ||
Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd)
@@ -452,7 +462,12 @@ export function computeIllnessCorrelation(
returnedDay && window.feltBetterDay
? dayDiff(window.feltBetterDay, returnedDay)
: null;
returns.push({ type, returnedDay, gapDays });
returns.push({
type,
returnedDay,
gapDays,
adverse: vitalDeviatedAdversely,
});
}
}
+16
View File
@@ -322,10 +322,26 @@ const chronotypeResource = z.object({
.describe("Free-day nights still needed before a band is asserted."),
});
const averageSleepResource = z.object({
state: z.enum(["partial", "ready"]),
averageMinutes: z
.number()
.int()
.nonnegative()
.describe("Mean asleep minutes per scorable night (0 while partial)."),
nightsCounted: z.number().int().nonnegative(),
nightsUntilReady: z
.number()
.int()
.nonnegative()
.describe("Scorable nights still needed before the average is asserted."),
});
const sleepRhythmResponse = z
.object({
sleepDebt: sleepDebtResource,
chronotype: chronotypeResource,
averagePerNight: averageSleepResource,
})
.meta({
id: "SleepRhythmResource",