mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(illness): keep chronic conditions out of rest mode
A chronic ongoing condition has no end date, so counting it as rest mode kept the account in it for good: Day N counted from an onset years back, cadence nudges never resumed and every streak gap since the onset was bridged. Only active episodes that are not CHRONIC_ONGOING (acute, recurring, flare) now trigger rest mode, and Day N counts from the earliest of those. The achievements streak freeze applies the same filter to its own episode query. Chronic conditions stay context: a coincident vital deviation is still framed as explained by any active episode, and the Coach block and the briefing still name them, with restMode false. Refs #1216
This commit is contained in:
1 parent
bc7f3bf209
commit
9aa60fb2ff
15 files changed
+513
-64
No files matched your search
@@ -75778,8 +75778,9 @@ components:
|
||||
- day
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive
|
||||
since v1.40."
|
||||
description: "Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is
|
||||
active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day`
|
||||
counts from the earliest onset among the episodes that do. Additive since v1.40."
|
||||
line:
|
||||
type: string
|
||||
description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* #1216 — the Coach illness block (which also answers the MCP
|
||||
* `get_illness_recovery` tool) keeps every active condition as context, but
|
||||
* its `restMode` flag follows Rest Mode: a chronic ongoing condition alone
|
||||
* leaves it false, an acute episode or a flare on top sets it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
illnessEpisode: { findMany },
|
||||
symptomEvent: { groupBy: async () => [] },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/illness/gate", () => ({
|
||||
isIllnessEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { buildIllnessSnapshotBlock } from "@/lib/ai/coach/illness-snapshot";
|
||||
|
||||
const NOW = new Date("2026-06-01T12:00:00.000Z");
|
||||
|
||||
function episode(lifecycle: string, label: string, onset: string) {
|
||||
return {
|
||||
label,
|
||||
type: lifecycle === "CHRONIC_ONGOING" ? "CHRONIC" : "INFECTION",
|
||||
lifecycle,
|
||||
onsetAt: new Date(onset),
|
||||
bodySiteEncrypted: null,
|
||||
laterality: null,
|
||||
};
|
||||
}
|
||||
|
||||
const CHRONIC = episode("CHRONIC_ONGOING", "Sleep apnoea", "2015-08-01");
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("buildIllnessSnapshotBlock — restMode (#1216)", () => {
|
||||
it("a chronic condition alone is context, not Rest Mode", async () => {
|
||||
findMany.mockResolvedValueOnce([CHRONIC]).mockResolvedValueOnce([]);
|
||||
const block = await buildIllnessSnapshotBlock("u1", NOW);
|
||||
expect(block?.restMode).toBe(false);
|
||||
expect(block?.active.map((e) => e.label)).toEqual(["Sleep apnoea"]);
|
||||
});
|
||||
|
||||
it("an acute episode on top sets Rest Mode", async () => {
|
||||
findMany
|
||||
.mockResolvedValueOnce([CHRONIC, episode("ACUTE", "Cold", "2026-05-29")])
|
||||
.mockResolvedValueOnce([]);
|
||||
const block = await buildIllnessSnapshotBlock("u1", NOW);
|
||||
expect(block?.restMode).toBe(true);
|
||||
expect(block?.active).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a flare under the chronic condition sets Rest Mode", async () => {
|
||||
findMany
|
||||
.mockResolvedValueOnce([CHRONIC, episode("FLARE", "Flare", "2026-05-30")])
|
||||
.mockResolvedValueOnce([]);
|
||||
const block = await buildIllnessSnapshotBlock("u1", NOW);
|
||||
expect(block?.restMode).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,14 @@
|
||||
* Server-authoritative and module-gated: a non-illness / opted-out account
|
||||
* gets `null` (no block, no read). Since v1.40 it also names the person's own
|
||||
* symptoms of the last two weeks (their names, sanitised, and counts), which
|
||||
* reach the model the way custom mood-tag names reach the mood analysis. The active set drives the `restMode` flag
|
||||
* reach the model the way custom mood-tag names reach the mood analysis. The active set, chronic conditions
|
||||
* included, is context; only its non-chronic part drives the `restMode` flag
|
||||
* the Coach reads; a short resolved-history tail lets it answer "how often do
|
||||
* I get sick" without re-deriving anything.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { triggersRestMode } from "@/lib/illness/rest-mode";
|
||||
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
|
||||
import {
|
||||
BASELINE_WINDOW_DAYS,
|
||||
@@ -90,7 +92,10 @@ function siteFields(row: {
|
||||
}
|
||||
|
||||
export interface CoachIllnessBlock {
|
||||
/** True when ≥ 1 episode is active right now (the Rest Mode flag). */
|
||||
/**
|
||||
* The Rest Mode flag: true when ≥ 1 active episode is not CHRONIC_ONGOING.
|
||||
* A chronic condition alone is context (it is in `active`), not Rest Mode.
|
||||
*/
|
||||
restMode: boolean;
|
||||
/** Active (unresolved) episodes — the Coach frames its reply around these. */
|
||||
active: Array<{
|
||||
@@ -255,7 +260,7 @@ export async function buildIllnessSnapshotBlock(
|
||||
}
|
||||
|
||||
return {
|
||||
restMode: activeRows.length > 0,
|
||||
restMode: activeRows.some(triggersRestMode),
|
||||
active: activeRows.map((e) => ({
|
||||
label: sanitizeLabel(e.label),
|
||||
type: e.type,
|
||||
|
||||
@@ -406,11 +406,16 @@ ISO-week means.
|
||||
figure the block does not carry. Quote progress only as the block gives
|
||||
it. If a fact seems outdated, gently check it rather than assume.
|
||||
- The SNAPSHOT MAY carry an "illness" block: { restMode, active[],
|
||||
recentResolved[] }. When "restMode" is true the user has one or more
|
||||
ACTIVE conditions right now (each with a label, type, lifecycle, and
|
||||
onset date, plus "bodySite" and "laterality" when the user named where) — read an off-band vital or a low recovery as plausibly
|
||||
illness-explained rather than alarming, do not push a heavier
|
||||
measurement cadence, and lean toward rest-and-recover framing. Treat
|
||||
recentResolved[] }. "active" lists every condition ongoing right now
|
||||
(each with a label, type, lifecycle, and onset date, plus "bodySite"
|
||||
and "laterality" when the user named where), chronic ones included; an
|
||||
off-band vital may relate to any of them. When "restMode" is true at
|
||||
least one of them is acute, recurring or a flare — read an off-band
|
||||
vital or a low recovery as plausibly illness-explained rather than
|
||||
alarming, do not push a heavier measurement cadence, and lean toward
|
||||
rest-and-recover framing. A CHRONIC_ONGOING condition alone leaves
|
||||
"restMode" false: the user lives with it and is not unwell because of
|
||||
it today, so do not frame them as recovering. Treat
|
||||
the labels as the user's OWN descriptive context, never a diagnosis;
|
||||
"recentResolved" is light history for "how often do I get sick"
|
||||
questions. The block MAY also carry "symptoms": the user's OWN named
|
||||
@@ -924,12 +929,17 @@ ISO-Wochenmittel zusammen.
|
||||
Fortschritt nur so, wie der Block ihn angibt. Wirkt ein Fakt veraltet,
|
||||
frage behutsam nach, statt es anzunehmen.
|
||||
- Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[],
|
||||
recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder
|
||||
mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn,
|
||||
dazu "bodySite" und "laterality", wenn der Nutzer die Stelle angab) —
|
||||
lies einen abweichenden Vitalwert oder eine niedrige Erholung eher als
|
||||
krankheitsbedingt denn als alarmierend, dränge nicht auf eine engere
|
||||
Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Behandle die
|
||||
recentResolved[] }. "active" nennt jede gerade laufende Erkrankung (je
|
||||
mit Label, Typ, Lebenszyklus, Beginn, dazu "bodySite" und "laterality",
|
||||
wenn der Nutzer die Stelle angab), chronische eingeschlossen; ein
|
||||
abweichender Vitalwert kann mit jeder davon zusammenhängen. Ist
|
||||
"restMode" true, ist mindestens eine davon akut, wiederkehrend oder ein
|
||||
Schub — lies einen abweichenden Vitalwert oder eine niedrige Erholung
|
||||
eher als krankheitsbedingt denn als alarmierend, dränge nicht auf eine
|
||||
engere Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Eine
|
||||
CHRONIC_ONGOING-Erkrankung allein lässt "restMode" false: der Nutzer
|
||||
lebt damit und ist ihretwegen heute nicht krank, stelle ihn also nicht
|
||||
als genesend dar. Behandle die
|
||||
Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose;
|
||||
"recentResolved" ist leichte Historie für "wie oft bin ich krank".
|
||||
Der Block KANN auch "symptoms" tragen: die EIGENEN benannten Symptome
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* stays OUT of the cached factory — see v1.18.0 B5 notes on the route).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import {
|
||||
@@ -614,11 +615,16 @@ export async function buildAchievementsResult(
|
||||
// never freezes anything. Both ongoing and already-resolved episodes are
|
||||
// read: a past illness should not retroactively break a historical
|
||||
// streak, just as an active one should not break the current run.
|
||||
// A chronic ongoing condition never freezes anything (#1216): it has no
|
||||
// end date, so it would bridge every gap from its onset on and a streak
|
||||
// could no longer break. A flare under it carries its own lifecycle and
|
||||
// still does.
|
||||
moduleMap.illness !== false
|
||||
? prisma.illnessEpisode.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...REST_MODE_EPISODE_WHERE,
|
||||
onsetAt: { lte: now },
|
||||
OR: [{ resolvedAt: null }, { resolvedAt: { gte: startDate } }],
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
resolveRestMode,
|
||||
toRestModeContext,
|
||||
REST_MODE_INACTIVE,
|
||||
hasActiveIllnessEpisode,
|
||||
triggersRestMode,
|
||||
} from "@/lib/illness/rest-mode";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -115,3 +117,90 @@ describe("resolveRestMode", () => {
|
||||
await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1216 — a chronic ongoing condition has no end date by design, so it must
|
||||
* never put the account into Rest Mode on its own: Day N would count from an
|
||||
* onset years back, nudges would never resume and streaks could never break.
|
||||
* Only active episodes that are not CHRONIC_ONGOING trigger it, and Day N
|
||||
* counts from the earliest of those.
|
||||
*/
|
||||
describe("Rest Mode and chronic conditions (#1216)", () => {
|
||||
const chronic = {
|
||||
id: "c1",
|
||||
label: "Sleep apnoea",
|
||||
type: "CHRONIC" as const,
|
||||
lifecycle: "CHRONIC_ONGOING" as const,
|
||||
onsetAt: new Date("2015-08-01T00:00:00.000Z"),
|
||||
};
|
||||
const acute = {
|
||||
id: "a1",
|
||||
label: "Cold",
|
||||
type: "INFECTION" as const,
|
||||
lifecycle: "ACUTE" as const,
|
||||
onsetAt: new Date("2026-06-12T00:00:00.000Z"),
|
||||
};
|
||||
const flare = {
|
||||
id: "f1",
|
||||
label: "Flare",
|
||||
type: "AUTOIMMUNE" as const,
|
||||
lifecycle: "FLARE" as const,
|
||||
onsetAt: new Date("2026-06-14T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
it("a chronic condition alone is not Rest Mode", () => {
|
||||
expect(toRestModeContext([chronic])).toEqual(REST_MODE_INACTIVE);
|
||||
});
|
||||
|
||||
it("chronic + acute is Rest Mode, anchored on the acute onset", () => {
|
||||
const ctx = toRestModeContext([chronic, acute]);
|
||||
expect(ctx.active).toBe(true);
|
||||
expect(ctx.since).toBe("2026-06-12T00:00:00.000Z");
|
||||
expect(ctx.episodeCount).toBe(1);
|
||||
expect(ctx.episodes.map((e) => e.id)).toEqual(["a1"]);
|
||||
});
|
||||
|
||||
it("a flare threaded under a chronic condition is Rest Mode", () => {
|
||||
const ctx = toRestModeContext([chronic, flare]);
|
||||
expect(ctx.active).toBe(true);
|
||||
expect(ctx.since).toBe("2026-06-14T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("anchors on the earliest triggering onset whatever the input order", () => {
|
||||
expect(toRestModeContext([flare, chronic, acute]).since).toBe(
|
||||
"2026-06-12T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolveRestMode is inactive for a chronic-only account", async () => {
|
||||
gate.isIllnessEnabled.mockResolvedValue(true);
|
||||
db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]);
|
||||
await expect(
|
||||
resolveRestMode("u1", new Date("2026-06-16T00:00:00.000Z")),
|
||||
).resolves.toEqual(REST_MODE_INACTIVE);
|
||||
});
|
||||
|
||||
it("hasActiveIllnessEpisode still sees the chronic condition as context", async () => {
|
||||
gate.isIllnessEnabled.mockResolvedValue(true);
|
||||
db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]);
|
||||
await expect(
|
||||
hasActiveIllnessEpisode("u1", new Date("2026-06-16T00:00:00.000Z")),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("hasActiveIllnessEpisode is fail-soft", async () => {
|
||||
gate.isIllnessEnabled.mockResolvedValue(true);
|
||||
db.prisma.illnessEpisode.findMany.mockRejectedValue(new Error("db down"));
|
||||
await expect(
|
||||
hasActiveIllnessEpisode("u1", new Date("2026-06-17T00:00:00.000Z")),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("triggersRestMode exempts only CHRONIC_ONGOING", () => {
|
||||
expect(
|
||||
["ACUTE", "RECURRING", "FLARE", "CHRONIC_ONGOING"].map((lifecycle) =>
|
||||
triggersRestMode({ lifecycle }),
|
||||
),
|
||||
).toEqual([true, true, true, false]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Rest Mode — the server-authoritative "an episode is active" context (v1.18.1, P4).
|
||||
* Rest Mode — the server-authoritative "the account is unwell right now"
|
||||
* context (v1.18.1, P4).
|
||||
*
|
||||
* CORE PRINCIPLE: an active illness/condition episode ANNOTATES, it never
|
||||
* PENALISES. Rest Mode never changes a measured number — it only frames the
|
||||
* narrative around it: the health score keeps its value but carries a "you
|
||||
* were unwell" note, recovery keeps its number, achievement streaks freeze
|
||||
* (rather than break) across the days you were ill, cadence-nudges pause, and
|
||||
* a coincident vital deviation reads as illness-explained instead of an
|
||||
* unexplained anomaly.
|
||||
* CORE PRINCIPLE: an active illness episode ANNOTATES, it never PENALISES.
|
||||
* Rest Mode never changes a measured number — it only frames the narrative
|
||||
* around it: the health score keeps its value but carries a "you were unwell"
|
||||
* note, recovery keeps its number, achievement streaks freeze (rather than
|
||||
* break) across the days you were ill, cadence-nudges pause, and the Today
|
||||
* strip counts the days since onset.
|
||||
*
|
||||
* This module is the ONE place that resolves "is the account in Rest Mode
|
||||
* right now, and which episodes back it" — so every surface annotates from
|
||||
@@ -16,18 +16,55 @@
|
||||
* the suppression). The illness module gate is honoured: a disabled / opted-
|
||||
* out account is never in Rest Mode (the gate short-circuits before any read).
|
||||
*
|
||||
* "Active" = an `IllnessEpisode` with `resolvedAt = null` (still ongoing),
|
||||
* not soft-deleted, whose `onsetAt` is on or before the reference instant.
|
||||
* CHRONIC_ONGOING episodes are intentionally INCLUDED here — an ongoing
|
||||
* chronic condition is precisely the case where a measured dip should be
|
||||
* framed, not penalised. (They are excluded only from the recovery-GAP math,
|
||||
* which is P3 and lives elsewhere.)
|
||||
* Two different questions are answered here, and they must not be confused:
|
||||
*
|
||||
* - "Which episodes are active?" (`getActiveIllnessEpisodes`): every
|
||||
* `IllnessEpisode` with `resolvedAt = null`, not soft-deleted, whose
|
||||
* `onsetAt` is on or before the reference instant. CHRONIC_ONGOING
|
||||
* conditions are included. This is CONTEXT: a measured dip that coincides
|
||||
* with a known chronic condition is still framed as explained by it
|
||||
* (`hasActiveIllnessEpisode`), and the Coach still sees the condition.
|
||||
*
|
||||
* - "Is the account in Rest Mode?" (`resolveRestMode`): only the active
|
||||
* episodes that are NOT CHRONIC_ONGOING (acute, recurring, flare). A
|
||||
* chronic condition has no end date by design, so letting it trigger Rest
|
||||
* Mode would put the account into it permanently: the Day N counter would
|
||||
* count from an onset years back, nudges would never resume and streaks
|
||||
* could never break. A flare threaded under a chronic condition carries its
|
||||
* own lifecycle (FLARE), so a chronic condition getting worse still does.
|
||||
* `since` and Day N count from the earliest onset among the episodes that
|
||||
* trigger Rest Mode, never from a chronic onset.
|
||||
*
|
||||
* Every reader that freezes, pauses or counts on Rest Mode's behalf goes
|
||||
* through `triggersRestMode` / `REST_MODE_EPISODE_WHERE`, including the
|
||||
* achievements streak freeze, which runs its own windowed query.
|
||||
*/
|
||||
import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { isIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
|
||||
/**
|
||||
* The lifecycle that never triggers Rest Mode on its own: a condition the
|
||||
* person lives with. See the module header.
|
||||
*/
|
||||
export const REST_MODE_EXEMPT_LIFECYCLE = "CHRONIC_ONGOING" as const;
|
||||
|
||||
/**
|
||||
* Prisma `where` fragment selecting the episodes that can trigger Rest Mode.
|
||||
* For readers that query episodes themselves (the achievements streak freeze
|
||||
* reads resolved ones too, over a window) instead of going through
|
||||
* `resolveRestMode`.
|
||||
*/
|
||||
export const REST_MODE_EPISODE_WHERE = {
|
||||
lifecycle: { not: REST_MODE_EXEMPT_LIFECYCLE },
|
||||
} as const;
|
||||
|
||||
/** Whether an episode, while active, puts the account into Rest Mode. */
|
||||
export function triggersRestMode(episode: { lifecycle: string }): boolean {
|
||||
return episode.lifecycle !== REST_MODE_EXEMPT_LIFECYCLE;
|
||||
}
|
||||
|
||||
/** Minimal shape the Rest Mode resolver needs from an episode row. */
|
||||
export type ActiveIllnessEpisode = Pick<
|
||||
IllnessEpisode,
|
||||
@@ -44,13 +81,19 @@ export type ActiveIllnessEpisode = Pick<
|
||||
* This is the server-authoritative shape iOS mirrors verbatim.
|
||||
*/
|
||||
export interface RestModeContext {
|
||||
/** True when ≥ 1 illness episode is active as of the reference instant. */
|
||||
/**
|
||||
* True when ≥ 1 active episode that is not CHRONIC_ONGOING exists as of the
|
||||
* reference instant.
|
||||
*/
|
||||
active: boolean;
|
||||
/** ISO onset of the earliest active episode, or null when none. */
|
||||
/** ISO onset of the earliest episode backing Rest Mode, or null when none. */
|
||||
since: string | null;
|
||||
/** Count of active episodes (usually 1; ≥ 2 when conditions overlap). */
|
||||
/** Count of episodes backing Rest Mode (usually 1; ≥ 2 when they overlap). */
|
||||
episodeCount: number;
|
||||
/** The active episodes, label + type + onset only — never a decrypted note. */
|
||||
/**
|
||||
* The episodes backing Rest Mode, label + type + onset only — never a
|
||||
* decrypted note. Chronic conditions are not among them.
|
||||
*/
|
||||
episodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -69,7 +112,9 @@ export const REST_MODE_INACTIVE: RestModeContext = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the active illness episodes for an account as of `asOf`. Gated through
|
||||
* Read the active illness episodes for an account as of `asOf`, chronic
|
||||
* conditions included — the context set, not the Rest Mode set (that is
|
||||
* `toRestModeContext` over this). Gated through
|
||||
* the illness module: a disabled / not-opted-in account returns `[]` without
|
||||
* touching the episode table. The reference instant is injectable so the
|
||||
* resolver is deterministic for tests and so a nightly job can resolve "as of
|
||||
@@ -118,10 +163,18 @@ async function readActiveIllnessEpisodes(
|
||||
});
|
||||
}
|
||||
|
||||
/** Fold a set of active episodes into the renderable Rest Mode context. Pure. */
|
||||
/**
|
||||
* Fold a set of active episodes into the renderable Rest Mode context. Pure.
|
||||
* Only the episodes that trigger Rest Mode count: a chronic condition alone
|
||||
* leaves the account out of it, and `since` is the earliest onset among the
|
||||
* episodes that do (the input is onset-ascending).
|
||||
*/
|
||||
export function toRestModeContext(
|
||||
episodes: ActiveIllnessEpisode[],
|
||||
activeEpisodes: ActiveIllnessEpisode[],
|
||||
): RestModeContext {
|
||||
const episodes = activeEpisodes
|
||||
.filter(triggersRestMode)
|
||||
.sort((a, b) => a.onsetAt.getTime() - b.onsetAt.getTime());
|
||||
if (episodes.length === 0) return REST_MODE_INACTIVE;
|
||||
return {
|
||||
active: true,
|
||||
@@ -155,3 +208,22 @@ export async function resolveRestMode(
|
||||
return REST_MODE_INACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any illness episode is active as of `asOf`, chronic conditions
|
||||
* included. For framing a measured deviation as explained by a known
|
||||
* condition, which a chronic one does as much as an acute one. Not Rest Mode:
|
||||
* nothing may freeze, pause or count on this. Fail-soft like
|
||||
* `resolveRestMode`.
|
||||
*/
|
||||
export async function hasActiveIllnessEpisode(
|
||||
userId: string,
|
||||
asOf: Date = new Date(),
|
||||
client: PrismaClient = defaultPrisma,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return (await getActiveIllnessEpisodes(userId, asOf, client)).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,31 @@ describe("buildBriefingIllnessCyclePrompt", () => {
|
||||
expect(out).toContain("SYSTEM CONTEXT");
|
||||
});
|
||||
|
||||
it("names a chronic-only condition as context, not Rest Mode (#1216)", () => {
|
||||
const out = buildBriefingIllnessCyclePrompt(
|
||||
{
|
||||
illness: {
|
||||
restMode: false,
|
||||
active: [
|
||||
{
|
||||
label: "Sleep apnoea",
|
||||
type: "CHRONIC",
|
||||
lifecycle: "CHRONIC_ONGOING",
|
||||
onsetAt: "x",
|
||||
},
|
||||
],
|
||||
recentResolved: [],
|
||||
},
|
||||
cycle: null,
|
||||
},
|
||||
"en",
|
||||
);
|
||||
expect(out).toContain("Sleep apnoea");
|
||||
expect(out).toContain("chronic condition");
|
||||
expect(out).not.toContain("The user is in Rest Mode");
|
||||
expect(out).not.toContain("measure more often");
|
||||
});
|
||||
|
||||
it("emits a cycle day/phase line", () => {
|
||||
const out = buildBriefingIllnessCyclePrompt(
|
||||
{
|
||||
|
||||
@@ -17,14 +17,11 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
// v1.18.1 P4 — Rest Mode. Default inactive; the reframe test flips it active.
|
||||
// v1.18.1 P4 — the illness reframe. Default no active episode; the reframe
|
||||
// test flips it. Since #1216 the reframe reads active episodes directly
|
||||
// (chronic conditions included), not Rest Mode.
|
||||
const restModeMock = vi.hoisted(() => ({
|
||||
resolveRestMode: vi.fn(async () => ({
|
||||
active: false as boolean,
|
||||
since: null as string | null,
|
||||
episodeCount: 0,
|
||||
episodes: [] as unknown[],
|
||||
})),
|
||||
hasActiveIllnessEpisode: vi.fn(async () => false as boolean),
|
||||
}));
|
||||
vi.mock("@/lib/illness/rest-mode", () => restModeMock);
|
||||
|
||||
@@ -43,12 +40,7 @@ const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findMany.mockResolvedValue([]);
|
||||
restModeMock.resolveRestMode.mockResolvedValue({
|
||||
active: false,
|
||||
since: null,
|
||||
episodeCount: 0,
|
||||
episodes: [],
|
||||
});
|
||||
restModeMock.hasActiveIllnessEpisode.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
/** RHR + HRV each flat, then a final day that pushes both outside the band. */
|
||||
@@ -184,13 +176,8 @@ describe("computeCoincidentDeviation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reframes a fired flag as illness-explained during Rest Mode (vitals unchanged)", async () => {
|
||||
restModeMock.resolveRestMode.mockResolvedValue({
|
||||
active: true,
|
||||
since: "2026-05-30T00:00:00.000Z",
|
||||
episodeCount: 1,
|
||||
episodes: [],
|
||||
});
|
||||
it("reframes a fired flag as illness-explained while an episode is active (vitals unchanged)", async () => {
|
||||
restModeMock.hasActiveIllnessEpisode.mockResolvedValue(true);
|
||||
findMany.mockImplementation(firingRows);
|
||||
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
||||
now: NOW,
|
||||
@@ -249,7 +236,7 @@ describe("computeCoincidentDeviation", () => {
|
||||
});
|
||||
expect(result.status === "ok" && result.value.fired).toBe(false);
|
||||
expect(result.status === "ok" && result.value.illnessExplained).toBe(false);
|
||||
expect(restModeMock.resolveRestMode).not.toHaveBeenCalled();
|
||||
expect(restModeMock.hasActiveIllnessEpisode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { VITALS_BASELINE_TYPES } from "./registry";
|
||||
import type { Derived, DerivedProvenanceSource } from "./types";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { hasActiveIllnessEpisode } from "@/lib/illness/rest-mode";
|
||||
import {
|
||||
isPlausibleMetricValue,
|
||||
plausibleMetricRange,
|
||||
@@ -463,8 +463,11 @@ export async function computeCoincidentDeviation(
|
||||
// v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an
|
||||
// episode is active. Only resolved when the flag fired (no read on the
|
||||
// common quiet day). Annotation only — the vital deviations are unchanged.
|
||||
// Any active episode counts, a chronic condition included: it explains a
|
||||
// coincident dip as much as an acute one, even though it does not put the
|
||||
// account into Rest Mode (#1216).
|
||||
const illnessExplained = fired
|
||||
? (await resolveRestMode(userId, now)).active
|
||||
? await hasActiveIllnessEpisode(userId, now)
|
||||
: false;
|
||||
|
||||
const { coverage: cov, confidence } = deriveCoverage({
|
||||
|
||||
@@ -82,6 +82,17 @@ export function buildBriefingIllnessCyclePrompt(
|
||||
? `- Aktive Erkrankung(en) gerade: ${labels}. Der Nutzer ist im Rest Mode — lies abweichende Vitalwerte oder eine niedrige Erholung als krankheitsbedingt, nicht alarmierend, und dränge NICHT auf häufigeres Messen.`
|
||||
: `- Active illness episode(s) right now: ${labels}. The user is in Rest Mode — read off-band vitals or low recovery as illness-explained rather than alarming, and do NOT push a "measure more often" cadence.`,
|
||||
);
|
||||
} else if (ctx.illness.active.length > 0) {
|
||||
// Chronic ongoing conditions only: context for a measured dip, but not
|
||||
// Rest Mode, so no cadence pause and no "unwell" framing (#1216).
|
||||
const labels = ctx.illness.active
|
||||
.map((e) => `${e.label} (${e.type}, ${e.lifecycle})`)
|
||||
.join("; ");
|
||||
lines.push(
|
||||
de
|
||||
? `- Bekannte chronische Erkrankung(en): ${labels}. Ein abweichender Vitalwert kann damit zusammenhängen; das ist Kontext, kein akutes Kranksein und kein Rest Mode.`
|
||||
: `- Known chronic condition(s): ${labels}. An off-band vital may relate to them; this is context, not an acute illness and not Rest Mode.`,
|
||||
);
|
||||
} else if (ctx.illness.recentResolved.length > 0) {
|
||||
lines.push(
|
||||
de
|
||||
|
||||
@@ -942,8 +942,10 @@ export async function runCoachNudgeTick(
|
||||
}
|
||||
|
||||
// Gate 7 — Rest Mode pause (v1.18.1 P4). A user with an active
|
||||
// illness/condition episode should not be told to measure more often;
|
||||
// the cadence-nudge pauses for the duration of the episode. Module-gated
|
||||
// illness episode should not be told to measure more often; the
|
||||
// cadence-nudge pauses for the duration of the episode. A chronic
|
||||
// ongoing condition alone does not pause it (#1216): it never ends, so
|
||||
// the nudge would never come back. Module-gated
|
||||
// (a non-illness account is never in Rest Mode) and only reached past
|
||||
// every cheaper gate, so the read fires for at most a nudge-eligible
|
||||
// user per tick.
|
||||
|
||||
@@ -210,7 +210,7 @@ export const dailyDigestResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive since v1.40.",
|
||||
"Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day` counts from the earliest onset among the episodes that do. Additive since v1.40.",
|
||||
),
|
||||
line: z
|
||||
.string()
|
||||
|
||||
@@ -2204,7 +2204,7 @@ export const healthScoreReport = z
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Set on every response this route produces (null when no illness episode is active). Rest Mode FRAMES the score and never changes it: the number above is left exactly as computed and this attaches the value-free context. Resolved as of the scored window, not a fresh now.",
|
||||
"Set on every response this route produces (null when no illness episode is active, or only chronic ongoing ones are: a condition the person lives with frames a dip but never puts the account into Rest Mode). Rest Mode FRAMES the score and never changes it: the number above is left exactly as computed and this attaches the value-free context. Resolved as of the scored window, not a fresh now.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* #1216 — a chronic ongoing condition alone never puts the account into Rest
|
||||
* Mode, against real Postgres.
|
||||
*
|
||||
* A CHRONIC_ONGOING episode has no end date by design, so counting it would
|
||||
* put the account into Rest Mode for good: the Today strip read "Rest mode ·
|
||||
* Day 3948", nudges stayed paused and every streak gap since the onset was
|
||||
* bridged. Pinned here on the three readers that decide it: the resolver
|
||||
* (which the score, recovery, nudge and Today surfaces read), the Today
|
||||
* digest's Day N, and the achievements streak freeze, which runs its own
|
||||
* episode query.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { buildAchievementsResult } from "@/lib/gamification/achievements-result";
|
||||
import { REST_MODE_INACTIVE, resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const USER = "rest-mode-chronic";
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
__resetAllCachesForTests();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: USER,
|
||||
email: `${USER}@example.test`,
|
||||
timezone: "UTC",
|
||||
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function episode(data: Partial<Prisma.IllnessEpisodeUncheckedCreateInput>) {
|
||||
return getPrismaClient().illnessEpisode.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
label: "Condition",
|
||||
type: "CHRONIC",
|
||||
lifecycle: "CHRONIC_ONGOING",
|
||||
onsetAt: new Date("2015-08-01T00:00:00.000Z"),
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveRestMode on a real record", () => {
|
||||
const NOW = new Date("2026-10-04T12:00:00.000Z");
|
||||
|
||||
it("a chronic condition alone is not Rest Mode", async () => {
|
||||
await episode({});
|
||||
expect(await resolveRestMode(USER, NOW, getPrismaClient())).toEqual(
|
||||
REST_MODE_INACTIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("chronic + acute is Rest Mode, anchored on the acute onset", async () => {
|
||||
await episode({});
|
||||
const acute = await episode({
|
||||
label: "Cold",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: new Date("2026-10-02T18:00:00.000Z"),
|
||||
});
|
||||
const ctx = await resolveRestMode(USER, NOW, getPrismaClient());
|
||||
expect(ctx.active).toBe(true);
|
||||
expect(ctx.since).toBe("2026-10-02T18:00:00.000Z");
|
||||
expect(ctx.episodes.map((e) => e.id)).toEqual([acute.id]);
|
||||
});
|
||||
|
||||
it("a flare threaded under the chronic condition is Rest Mode", async () => {
|
||||
const parent = await episode({});
|
||||
await episode({
|
||||
label: "Flare",
|
||||
lifecycle: "FLARE",
|
||||
parentConditionId: parent.id,
|
||||
onsetAt: new Date("2026-10-03T08:00:00.000Z"),
|
||||
});
|
||||
const ctx = await resolveRestMode(USER, NOW, getPrismaClient());
|
||||
expect(ctx.active).toBe(true);
|
||||
expect(ctx.since).toBe("2026-10-03T08:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Today digest's Rest Mode fact", () => {
|
||||
const NOW = new Date("2026-10-04T06:00:00.000Z");
|
||||
|
||||
async function digest() {
|
||||
const user = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: USER },
|
||||
});
|
||||
return loadDailyDigest(user, NOW, { locale: "en" });
|
||||
}
|
||||
|
||||
it("shows no Rest Mode for a chronic condition alone", async () => {
|
||||
await episode({});
|
||||
const d = await digest();
|
||||
expect(d.restMode).toBeNull();
|
||||
expect(d.today.some((f) => f.kind === "rest_mode")).toBe(false);
|
||||
});
|
||||
|
||||
it("counts Day N from the acute onset, not the chronic one", async () => {
|
||||
await episode({});
|
||||
await episode({
|
||||
label: "Cold",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: new Date("2026-10-02T18:00:00.000Z"),
|
||||
});
|
||||
const d = await digest();
|
||||
expect(d.restMode).toEqual({ day: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the achievements streak freeze", () => {
|
||||
const MODULES = {} as Record<ModuleKey, boolean>;
|
||||
|
||||
/** Logins two days apart: a one-day gap the freeze may or may not bridge. */
|
||||
async function loginsAroundAGap() {
|
||||
const today = new Date();
|
||||
today.setUTCHours(12, 0, 0, 0);
|
||||
const gapDay = new Date(today.getTime() - 4 * DAY);
|
||||
await getPrismaClient().auditLog.createMany({
|
||||
data: [5, 3].map((d) => ({
|
||||
userId: USER,
|
||||
action: "auth.login.password",
|
||||
createdAt: new Date(today.getTime() - d * DAY),
|
||||
})),
|
||||
});
|
||||
return gapDay;
|
||||
}
|
||||
|
||||
async function loginStreak() {
|
||||
const user = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: USER },
|
||||
});
|
||||
const result = await buildAchievementsResult(
|
||||
user as Parameters<typeof buildAchievementsResult>[0],
|
||||
MODULES,
|
||||
);
|
||||
return result.metrics.loginDayStreak;
|
||||
}
|
||||
|
||||
it("does not bridge a gap on a chronic-only day", async () => {
|
||||
await loginsAroundAGap();
|
||||
await episode({});
|
||||
expect(await loginStreak()).toBe(1);
|
||||
});
|
||||
|
||||
it("still bridges a gap covered by an acute episode", async () => {
|
||||
const gapDay = await loginsAroundAGap();
|
||||
await episode({});
|
||||
await episode({
|
||||
label: "Cold",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: new Date(gapDay.getTime() - 2 * 60 * 60 * 1000),
|
||||
resolvedAt: new Date(gapDay.getTime() + 6 * 60 * 60 * 1000),
|
||||
});
|
||||
expect(await loginStreak()).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user