mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
b9f396c72a
commit
e2ce064254
5 files changed
+116
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user