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:
Marc-André Bombeck committed 2026-10-14 18:57:01 +02:00
1 parent bc7f3bf209
commit 9aa60fb2ff
15 files changed
+513 -64

No files matched your search

+3 -2
View File
@@ -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);
});
});
+8 -3
View File
@@ -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,
+21 -11
View File
@@ -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]);
});
});
+93 -21
View File
@@ -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
+4 -2
View File
@@ -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.
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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({
+173
View File
@@ -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);
});
});