diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1754488a8..169747551 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/lib/ai/coach/__tests__/illness-snapshot-rest-mode.test.ts b/src/lib/ai/coach/__tests__/illness-snapshot-rest-mode.test.ts new file mode 100644 index 000000000..3dab6b398 --- /dev/null +++ b/src/lib/ai/coach/__tests__/illness-snapshot-rest-mode.test.ts @@ -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); + }); +}); diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts index ae6c97e09..097439ff7 100644 --- a/src/lib/ai/coach/illness-snapshot.ts +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -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, diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 264db9b17..5ee5600fe 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -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 diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index 1685916c1..e5d4cf9cd 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -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 } }], }, diff --git a/src/lib/illness/__tests__/rest-mode.test.ts b/src/lib/illness/__tests__/rest-mode.test.ts index 2d46ceb0e..533addb84 100644 --- a/src/lib/illness/__tests__/rest-mode.test.ts +++ b/src/lib/illness/__tests__/rest-mode.test.ts @@ -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]); + }); +}); diff --git a/src/lib/illness/rest-mode.ts b/src/lib/illness/rest-mode.ts index c1a1bf889..c271fa180 100644 --- a/src/lib/illness/rest-mode.ts +++ b/src/lib/illness/rest-mode.ts @@ -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 { + try { + return (await getActiveIllnessEpisodes(userId, asOf, client)).length > 0; + } catch { + return false; + } +} diff --git a/src/lib/insights/__tests__/illness-cycle-briefing.test.ts b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts index 6f5b66a23..90cb0396f 100644 --- a/src/lib/insights/__tests__/illness-cycle-briefing.test.ts +++ b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts @@ -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( { diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 76bdf0548..df95d5f05 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -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; 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(); }); }); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index bb6353542..cc70b3c53 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -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({ diff --git a/src/lib/insights/illness-cycle-briefing.ts b/src/lib/insights/illness-cycle-briefing.ts index f7fd7974d..56aadd6bc 100644 --- a/src/lib/insights/illness-cycle-briefing.ts +++ b/src/lib/insights/illness-cycle-briefing.ts @@ -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 diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 9b008ed54..e0c6598fe 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -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. diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 3917d885e..92845948e 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -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() diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 486ca50aa..f13be444c 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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({ diff --git a/tests/integration/rest-mode-chronic.test.ts b/tests/integration/rest-mode-chronic.test.ts new file mode 100644 index 000000000..201d458fc --- /dev/null +++ b/tests/integration/rest-mode-chronic.test.ts @@ -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) { + 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; + + /** 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[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); + }); +});