diff --git a/messages/de.json b/messages/de.json
index da3e0d99e..d5421a396 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -204,7 +204,14 @@
"eA1c": "Geschätzter HbA1c",
"cv": "Variabilität (VK%)",
"spotCaveat": "Dies sind Schätzwerte aus Einzelmessungen, kein Profil eines kontinuierlichen Messgeräts — als Tendenz und nicht als klinisches Urteil zu lesen."
- }
+ },
+ "illnessTitle": "Erkrankungen",
+ "illnessOngoing": "Andauernd",
+ "illnessColCondition": "Erkrankung",
+ "illnessColType": "Art",
+ "illnessColLifecycle": "Verlauf",
+ "illnessColOnset": "Beginn",
+ "illnessColResolved": "Abgeklungen"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -3462,7 +3469,8 @@
"watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig — behalte es in den nächsten Tagen im Blick.",
"firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.",
"factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.",
- "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies."
+ "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies.",
+ "illnessExplained": "Diese Abweichungen hängen wahrscheinlich mit der erfassten Erkrankung zusammen."
},
"trajectory": {
"cardTitle": "Kurzfrist-Projektion",
diff --git a/messages/en.json b/messages/en.json
index 44627ee4e..a1495e837 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -204,7 +204,14 @@
"eA1c": "Estimated A1C",
"cv": "Variability (CV%)",
"spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict."
- }
+ },
+ "illnessTitle": "Conditions & illnesses",
+ "illnessOngoing": "Ongoing",
+ "illnessColCondition": "Condition",
+ "illnessColType": "Type",
+ "illnessColLifecycle": "Course",
+ "illnessColOnset": "Onset",
+ "illnessColResolved": "Resolved"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -3462,7 +3469,8 @@
"watchVital": "Outside range: {vital}. Often just a one-off — worth a glance over the next few days.",
"firedHeadline": "{count} of your vitals are outside their personal range today.",
"factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.",
- "building": "Building your personal baselines — track a few more days and this will appear."
+ "building": "Building your personal baselines — track a few more days and this will appear.",
+ "illnessExplained": "These shifts likely reflect the condition you logged."
},
"trajectory": {
"cardTitle": "Short-horizon projection",
diff --git a/messages/es.json b/messages/es.json
index a0176f9e6..6c88b63ae 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -204,7 +204,14 @@
"eA1c": "A1C estimada",
"cv": "Variabilidad (CV%)",
"spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de monitor continuo — léelas como una tendencia, no como un veredicto clínico."
- }
+ },
+ "illnessTitle": "Afecciones y enfermedades",
+ "illnessOngoing": "En curso",
+ "illnessColCondition": "Afección",
+ "illnessColType": "Tipo",
+ "illnessColLifecycle": "Curso",
+ "illnessColOnset": "Inicio",
+ "illnessColResolved": "Resuelta"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -3462,7 +3469,8 @@
"watchVital": "Fuera de rango: {vital}. A menudo es algo puntual; conviene vigilarlo los próximos días.",
"firedHeadline": "Hoy, {count} de tus constantes están fuera de su rango personal.",
"factors": "Posibles factores, nunca una causa: un entrenamiento intenso, dormir mal, alcohol, altitud, estrés o una enfermedad. Es un aviso a partir de tus propias bases, no un diagnóstico.",
- "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá."
+ "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá.",
+ "illnessExplained": "Es probable que estas variaciones reflejen la afección que registraste."
},
"trajectory": {
"cardTitle": "Proyección a corto plazo",
diff --git a/messages/fr.json b/messages/fr.json
index 6254e9172..079a2b06f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -204,7 +204,14 @@
"eA1c": "HbA1c estimée",
"cv": "Variabilité (CV%)",
"spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, et non un profil de capteur continu — à lire comme une tendance, pas comme un verdict clinique."
- }
+ },
+ "illnessTitle": "Affections et maladies",
+ "illnessOngoing": "En cours",
+ "illnessColCondition": "Affection",
+ "illnessColType": "Type",
+ "illnessColLifecycle": "Évolution",
+ "illnessColOnset": "Début",
+ "illnessColResolved": "Résolue"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -3462,7 +3469,8 @@
"watchVital": "Hors plage : {vital}. Souvent ponctuel — à surveiller ces prochains jours.",
"firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.",
"factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.",
- "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra."
+ "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra.",
+ "illnessExplained": "Ces écarts reflètent probablement l’affection que vous avez consignée."
},
"trajectory": {
"cardTitle": "Projection à court terme",
diff --git a/messages/it.json b/messages/it.json
index baa8783eb..fcea894ef 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -204,7 +204,14 @@
"eA1c": "A1C stimata",
"cv": "Variabilità (CV%)",
"spotCaveat": "Sono stime da misurazioni occasionali, non un profilo da monitor continuo — vanno lette come una tendenza, non come un verdetto clinico."
- }
+ },
+ "illnessTitle": "Condizioni e malattie",
+ "illnessOngoing": "In corso",
+ "illnessColCondition": "Condizione",
+ "illnessColType": "Tipo",
+ "illnessColLifecycle": "Decorso",
+ "illnessColOnset": "Inizio",
+ "illnessColResolved": "Risolta"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -3462,7 +3469,8 @@
"watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato — tienilo d’occhio nei prossimi giorni.",
"firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.",
"factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.",
- "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà."
+ "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà.",
+ "illnessExplained": "Questi scostamenti riflettono probabilmente la condizione che hai registrato."
},
"trajectory": {
"cardTitle": "Proiezione a breve termine",
diff --git a/messages/pl.json b/messages/pl.json
index 3b218aed3..1c7b3ed76 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -204,7 +204,14 @@
"eA1c": "Szacowane HbA1c",
"cv": "Zmienność (CV%)",
"spotCaveat": "To szacunki z pojedynczych pomiarów, nie profil z monitora ciągłego — czytaj je jako kierunek zmian, a nie kliniczny werdykt."
- }
+ },
+ "illnessTitle": "Schorzenia i choroby",
+ "illnessOngoing": "Trwająca",
+ "illnessColCondition": "Schorzenie",
+ "illnessColType": "Typ",
+ "illnessColLifecycle": "Przebieg",
+ "illnessColOnset": "Początek",
+ "illnessColResolved": "Ustąpiła"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -3462,7 +3469,8 @@
"watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt — warto obserwować przez kilka dni.",
"firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.",
"factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.",
- "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi."
+ "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi.",
+ "illnessExplained": "Te odchylenia prawdopodobnie wynikają z zapisanego schorzenia."
},
"trajectory": {
"cardTitle": "Krótkoterminowy rzut",
diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts
index 749a7beed..2f9756c28 100644
--- a/src/app/api/analytics/route.ts
+++ b/src/app/api/analytics/route.ts
@@ -18,6 +18,7 @@ import {
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { isModuleEnabled } from "@/lib/modules/gate";
+import { resolveRestMode } from "@/lib/illness/rest-mode";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
@@ -444,6 +445,21 @@ async function buildAnalyticsResponse(user: AuthedUser) {
moodEnabled,
});
+ // v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is
+ // active we frame the score (not penalise it): the number above is left
+ // exactly as computed and we attach a value-free "you were unwell" context.
+ // Resolved server-side so iOS mirrors the same fact, never recomputing it.
+ if (healthScore) {
+ const restMode = await resolveRestMode(user.id);
+ healthScore.restMode = restMode.active
+ ? {
+ active: true,
+ since: restMode.since,
+ episodeCount: restMode.episodeCount,
+ }
+ : null;
+ }
+
return {
summaries: results,
bmi,
diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts
index 661a4fa47..67a9ba6eb 100644
--- a/src/app/api/export/health-record/__tests__/route.test.ts
+++ b/src/app/api/export/health-record/__tests__/route.test.ts
@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
@@ -98,6 +99,7 @@ beforeEach(() => {
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
// First findUnique = aggregator profile select; second = route KVNR select.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts
index 0b419743c..0504280dd 100644
--- a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -95,6 +96,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts
index 313813feb..3393acc51 100644
--- a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -81,6 +82,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
index 16d5465d1..9d88c4648 100644
--- a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -87,6 +88,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts
index 2b1821386..09c27ee6a 100644
--- a/src/app/api/gamification/achievements/route.ts
+++ b/src/app/api/gamification/achievements/route.ts
@@ -9,6 +9,7 @@ import {
ACHIEVEMENT_DEFINITIONS,
GAMIFICATION_ROLLOUT_AT,
applyDiscoveryFilter,
+ bridgeFrozenStreakGaps,
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
moduleForMetric,
@@ -90,6 +91,29 @@ function maxDate(a: Date, b: Date): Date {
return a > b ? a : b;
}
+/**
+ * v1.18.1 P4 — every Berlin-local day key covered by an illness episode
+ * (onset day through the resolved day, or through `now` while ongoing),
+ * inclusive on both ends. These are the days a streak is allowed to lapse
+ * across without breaking (the Rest Mode freeze). Uses the same
+ * `toBerlinDayKey` day anchoring every other streak series uses, so the
+ * frozen days line up with the qualifying days exactly.
+ */
+function collectIllnessDayKeys(
+ episodes: Array<{ onsetAt: Date; resolvedAt: Date | null }>,
+ now: Date,
+): Set {
+ const frozen = new Set();
+ for (const ep of episodes) {
+ const startSerial = dayKeyToSerial(toBerlinDayKey(ep.onsetAt));
+ const endSerial = dayKeyToSerial(toBerlinDayKey(ep.resolvedAt ?? now));
+ for (let serial = startSerial; serial <= endSerial; serial++) {
+ frozen.add(serialToDayKey(serial));
+ }
+ }
+ return frozen;
+}
+
function dayKeyToSerial(dayKey: string): number {
const [year, month, day] = dayKey.split("-").map(Number);
return Date.UTC(year, month - 1, day) / DAY_MS;
@@ -612,6 +636,7 @@ async function buildAchievementsResult(
moodEntries,
sleepMeasurements,
healthProfile,
+ illnessEpisodes,
] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -737,8 +762,33 @@ async function buildAchievementsResult(
updatedAt: true,
},
}),
+ // v1.18.1 P4 — illness episodes over the window, for the streak-freeze.
+ // Gated on the illness module so a non-illness account does no read and
+ // 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.
+ moduleMap.illness !== false
+ ? prisma.illnessEpisode.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ onsetAt: { lte: now },
+ OR: [{ resolvedAt: null }, { resolvedAt: { gte: startDate } }],
+ },
+ select: { onsetAt: true, resolvedAt: true },
+ })
+ : Promise.resolve([]),
]);
+ // v1.18.1 P4 — Rest Mode streak-freeze. Build the set of Berlin-local day
+ // keys covered by an illness episode (onset → resolved, or → now while
+ // ongoing). A day-streak that lapses only across these days is bridged
+ // rather than broken (see `bridgeFrozenStreakGaps`): being unwell pauses a
+ // streak, it does not fail it.
+ const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now);
+ const freeze = (dayKeys: string[]): string[] =>
+ bridgeFrozenStreakGaps(dayKeys, frozenIllnessDayKeys);
+
const schedulesByMedicationId = new Map(
medications.map((med) => [med.id, med.schedules]),
);
@@ -822,22 +872,25 @@ async function buildAchievementsResult(
totalTakenIntakes: takenIntakeDates.length,
overIntakeCount: intakeIssueMetrics.overIntakeCount,
skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount,
- bmiGreenStreak: calculateLongestStreak(healthSeries.bmi.dayKeys),
- bpGreenStreak: calculateLongestStreak(healthSeries.bp.dayKeys),
- pulseGreenStreak: calculateLongestStreak(healthSeries.pulse.dayKeys),
- onTimePerfectDayStreak: calculateLongestStreak(onTimeSeries.dayKeys),
- compliance80DayStreak: calculateLongestStreak(complianceSeries.dayKeys),
+ // v1.18.1 P4 — every day-streak runs through `freeze(...)` so an active
+ // or past illness episode bridges (does not break) the run across the
+ // days the user was unwell.
+ bmiGreenStreak: calculateLongestStreak(freeze(healthSeries.bmi.dayKeys)),
+ bpGreenStreak: calculateLongestStreak(freeze(healthSeries.bp.dayKeys)),
+ pulseGreenStreak: calculateLongestStreak(freeze(healthSeries.pulse.dayKeys)),
+ onTimePerfectDayStreak: calculateLongestStreak(freeze(onTimeSeries.dayKeys)),
+ compliance80DayStreak: calculateLongestStreak(freeze(complianceSeries.dayKeys)),
passkeyCreatedCount: passkeyCreatedDates.length,
passkeyLoginCount: passkeyLoginDates.length,
passwordLoginCount: passwordLoginDates.length,
- loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys),
+ loginDayStreak: calculateLongestStreak(freeze(loginDaySeries.dayKeys)),
bugReportCount: bugReportDates.length,
...expansionValues,
// v1.16.1 — care-routine metrics.
- missFreeDayStreak: calculateLongestStreak(missFreeSeries.dayKeys),
+ missFreeDayStreak: calculateLongestStreak(freeze(missFreeSeries.dayKeys)),
measurementConsistencyWeeks: weeklyConsistency.longestRunWeeks,
selfContextCompleteCount: selfContextComplete,
- sleepLogDayStreak: calculateLongestStreak(sleepSeries.dayKeys),
+ sleepLogDayStreak: calculateLongestStreak(freeze(sleepSeries.dayKeys)),
};
const completionDates: Partial> = {};
diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
index 9b64d26e0..ace97f219 100644
--- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx
+++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
@@ -41,13 +41,16 @@ function deviation(
}
function ok(
- value: CoincidentDeviationValue,
+ value: Omit &
+ Partial>,
band: "high" | "medium" | "low" | "draft" = "high",
): Resp {
return {
metric: "COINCIDENT_DEVIATION",
status: "ok",
- value,
+ // v1.18.1 P4 — default the Rest Mode reframe flag off so the existing
+ // fixtures stay valid; the reframe test passes `illnessExplained: true`.
+ value: { illnessExplained: false, ...value },
coverage: {
requiredInputs: 5,
presentInputs: 5,
@@ -202,6 +205,32 @@ describe("", () => {
expect(html).not.toContain('role="alert"');
});
+ it("reframes a fired flag as illness-explained during Rest Mode (no extra alarm)", () => {
+ mock(
+ ok({
+ fired: true,
+ day: "2026-06-03",
+ illnessExplained: true,
+ vitals: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ contributing: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ }),
+ );
+ const html = render();
+ expect(html).toContain('data-state="fired"');
+ // The reframe line replaces the open-ended "possible factors" copy.
+ expect(html).toContain("the condition you logged");
+ expect(html).not.toContain("Possible factors — never a cause");
+ // Annotate, don't escalate: no new destructive colour beyond the amber the
+ // fired state already carries.
+ expect(html).not.toContain("text-destructive");
+ });
+
it("softens a fired flag to the watch tone when history is thin", () => {
mock(
ok(
diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx
index dea43da1d..d7ddcbd24 100644
--- a/src/components/insights/coincident-deviation-card.tsx
+++ b/src/components/insights/coincident-deviation-card.tsx
@@ -278,7 +278,13 @@ export function CoincidentDeviationCard({
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-factors"
>
- {t("insights.derived.coincident.factors")}
+ {/* v1.18.1 P4 — Rest Mode reframe. When an illness episode is active
+ the deviations have a known explanation, so the card states it
+ plainly instead of leaving "possible factors" open. Neutral copy,
+ no extra colour. */}
+ {v.illnessExplained
+ ? t("insights.derived.coincident.illnessExplained")
+ : t("insights.derived.coincident.factors")}
);
diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts
index 68cc6494f..22cc7601f 100644
--- a/src/lib/__tests__/doctor-report-pdf-core.test.ts
+++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts
@@ -488,3 +488,62 @@ describe("doctor-report clinical glucose panel", () => {
expect(text).not.toContain("Glucose overview (clinical)");
});
});
+
+// v1.18.1 P4 — illness / condition episodes section. Retrospective + factual:
+// label, type, course, onset, resolved. Present only when the aggregator
+// populated `illnessEpisodes` (module on + episode in window).
+describe("doctor-report illness section", () => {
+ it("prints the conditions table with label, type, and dates", async () => {
+ const data = makeData({
+ illnessEpisodes: [
+ {
+ label: "Erkältung",
+ type: "INFECTION",
+ lifecycle: "ACUTE",
+ onsetAt: "2026-04-01T00:00:00.000Z",
+ resolvedAt: "2026-04-10T00:00:00.000Z",
+ },
+ ],
+ });
+ const bytes = renderDoctorReportPdfBytes(data, {
+ t: getServerTranslator("en").t,
+ locale: "en",
+ now: FIXED_NOW,
+ });
+ const text = await extractText(bytes);
+ expect(text).toContain("Conditions & illnesses");
+ expect(text).toContain("Erkältung");
+ });
+
+ it("marks an ongoing (unresolved) episode rather than a blank cell", async () => {
+ const data = makeData({
+ illnessEpisodes: [
+ {
+ label: "Heuschnupfen",
+ type: "ALLERGY",
+ lifecycle: "RECURRING",
+ onsetAt: "2026-04-01T00:00:00.000Z",
+ resolvedAt: null,
+ },
+ ],
+ });
+ const bytes = renderDoctorReportPdfBytes(data, {
+ t: getServerTranslator("en").t,
+ locale: "en",
+ now: FIXED_NOW,
+ });
+ const text = await extractText(bytes);
+ expect(text).toContain("Heuschnupfen");
+ expect(text).toContain("Ongoing");
+ });
+
+ it("omits the section entirely when there are no episodes", async () => {
+ const bytes = renderDoctorReportPdfBytes(makeData(), {
+ t: getServerTranslator("en").t,
+ locale: "en",
+ now: FIXED_NOW,
+ });
+ const text = await extractText(bytes);
+ expect(text).not.toContain("Conditions & illnesses");
+ });
+});
diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts
index 96820f0d4..f7df0b8ed 100644
--- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn(async () => []) },
},
}));
diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts
index 99aa27a74..ca4d9653e 100644
--- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn(async () => []) },
},
}));
diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts
index 102241c92..4000f8ca9 100644
--- a/src/lib/ai/coach/__tests__/snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot.test.ts
@@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
+ // v1.18.1 P4 — the illness context block reads active + recent episodes.
+ illnessEpisode: { findMany: vi.fn(async () => []) },
},
}));
diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts
new file mode 100644
index 000000000..12f948752
--- /dev/null
+++ b/src/lib/ai/coach/illness-snapshot.ts
@@ -0,0 +1,84 @@
+/**
+ * v1.18.1 P4 — illness / condition context block for the Coach snapshot.
+ *
+ * Gives the Coach the FACT that the user has (or recently had) a condition so
+ * its replies are grounded: it should not push a "measure more often" cadence
+ * at someone who is unwell, and it should read a low recovery / off-band vital
+ * as illness-explained rather than alarming. This is CONTEXT, not a metric —
+ * the block carries no measured number, only labels + lifecycle + dates (never
+ * the decrypted free-text note, which must not reach the prompt).
+ *
+ * Server-authoritative and module-gated: a non-illness / opted-out account
+ * gets `null` (no block, no read). The active set 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";
+
+/** How many recently-resolved episodes to include as history context. */
+const RESOLVED_HISTORY_LIMIT = 6;
+
+export interface CoachIllnessBlock {
+ /** True when ≥ 1 episode is active right now (the Rest Mode flag). */
+ restMode: boolean;
+ /** Active (unresolved) episodes — the Coach frames its reply around these. */
+ active: Array<{
+ label: string;
+ type: string;
+ lifecycle: string;
+ onsetAt: string;
+ }>;
+ /** Recently-resolved episodes, newest first — light history context. */
+ recentResolved: Array<{
+ label: string;
+ type: string;
+ onsetAt: string;
+ resolvedAt: string;
+ }>;
+}
+
+/**
+ * Build the illness context block, or `null` when the module is off / the
+ * account has no episode history. Reads label + lifecycle + dates only — the
+ * `noteEncrypted` column is never selected.
+ */
+export async function buildIllnessSnapshotBlock(
+ userId: string,
+ now: Date = new Date(),
+): Promise {
+ if (!(await isIllnessEnabled(userId))) return null;
+
+ const [activeRows, resolvedRows] = await Promise.all([
+ prisma.illnessEpisode.findMany({
+ where: { userId, deletedAt: null, resolvedAt: null, onsetAt: { lte: now } },
+ orderBy: { onsetAt: "asc" },
+ select: { label: true, type: true, lifecycle: true, onsetAt: true },
+ }),
+ prisma.illnessEpisode.findMany({
+ where: { userId, deletedAt: null, resolvedAt: { not: null } },
+ orderBy: { resolvedAt: "desc" },
+ take: RESOLVED_HISTORY_LIMIT,
+ select: { label: true, type: true, onsetAt: true, resolvedAt: true },
+ }),
+ ]);
+
+ if (activeRows.length === 0 && resolvedRows.length === 0) return null;
+
+ return {
+ restMode: activeRows.length > 0,
+ active: activeRows.map((e) => ({
+ label: e.label,
+ type: e.type,
+ lifecycle: e.lifecycle,
+ onsetAt: e.onsetAt.toISOString(),
+ })),
+ recentResolved: resolvedRows.map((e) => ({
+ label: e.label,
+ type: e.type,
+ onsetAt: e.onsetAt.toISOString(),
+ // `resolvedAt` is non-null by the query filter above.
+ resolvedAt: (e.resolvedAt as Date).toISOString(),
+ })),
+ };
+}
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 3f2402b35..c7639ecfb 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -47,6 +47,7 @@ import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
+import { buildIllnessSnapshotBlock } from "./illness-snapshot";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
@@ -1052,6 +1053,13 @@ async function buildCoachSnapshotImpl(
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
: null;
+ // v1.18.1 P4 — illness/condition context. Always attempted (the helper is
+ // module-gated internally and short-circuits to null for a non-illness
+ // account). It is CONTEXT, not a scope-gated metric: appended like
+ // anthropometrics/scope below without a `registerBlock` so the budget
+ // degrader never sheds it — the Coach needs to know about Rest Mode.
+ const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now);
+
const [
moodRows,
complianceMeds,
@@ -1065,6 +1073,7 @@ async function buildCoachSnapshotImpl(
trajectoryBlock,
memoryBlock,
cycleBlock,
+ illnessBlock,
] = await Promise.all([
moodRowsPromise,
complianceMedsPromise,
@@ -1078,6 +1087,7 @@ async function buildCoachSnapshotImpl(
trajectoryBlockPromise,
memoryBlockPromise,
cycleBlockPromise,
+ illnessBlockPromise,
]);
const byType = (t: string) =>
@@ -1850,6 +1860,14 @@ async function buildCoachSnapshotImpl(
registerBlock("cycle", "skin_temp");
}
+ // v1.18.1 P4 — illness/condition context. Small + load-bearing, so it is
+ // attached WITHOUT a cluster registration (like scope/anthropometrics): the
+ // budget degrader never sheds it, the Coach always knows whether the user is
+ // in Rest Mode. Labels + lifecycle + dates only — no decrypted note.
+ if (illnessBlock) {
+ snapshot.illness = illnessBlock;
+ }
+
if (Object.keys(snapshot).length === 0) {
metrics.add("general");
}
diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts
index f2af6973b..8e30ee151 100644
--- a/src/lib/analytics/health-score.ts
+++ b/src/lib/analytics/health-score.ts
@@ -150,6 +150,27 @@ export interface HealthScoreResult {
};
/** Difference vs. last week's score; null when no historical input. */
delta: number | null;
+ /**
+ * v1.18.1 P4 — Rest Mode context. When an illness/condition episode is
+ * active the route attaches this so the surface can frame the score ("you
+ * were unwell during this window") WITHOUT changing it: `computeHealthScore`
+ * stays pure and the number is never penalised. Absent / null when the
+ * account is not in Rest Mode (the pure computation never sets it).
+ */
+ restMode?: RestModeAnnotation | null;
+}
+
+/**
+ * v1.18.1 P4 — the score-payload view of Rest Mode. A compact, value-free
+ * annotation: never a decrypted note, never a score adjustment. iOS mirrors it
+ * verbatim (server-authoritative). The full context lives on
+ * `@/lib/illness/rest-mode`; this is the slice a score result carries.
+ */
+export interface RestModeAnnotation {
+ active: boolean;
+ /** ISO onset of the earliest active episode. */
+ since: string | null;
+ episodeCount: number;
}
const BASE_WEIGHTS = {
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index c909bdac2..647f0ae27 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -287,6 +287,29 @@ export interface DoctorReportData {
/** Count of readings for this analyte in the window (≥ 1). */
count: number;
}> | null;
+ /**
+ * v1.18.1 P4 — illness / condition episodes overlapping the report window.
+ * Populated ONLY when the `illness` module is enabled (born-gated opt-in);
+ * the user turned the journal on and is sharing it deliberately, so the
+ * privacy stance matches labs (ON when the module is on), not mood / cycle.
+ * Statistics + labels only — the free-text note is NEVER read here, so no
+ * encrypted plaintext can leak into the report. Each entry feeds a FHIR
+ * `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the
+ * module is off or no episode overlaps the window; both builders then skip
+ * the section. Optional so pre-v1.18.1 fixtures still typecheck.
+ */
+ illnessEpisodes?: Array<{
+ /** User-facing condition label (e.g. "Erkältung"). */
+ label: string;
+ /** Broad condition class (INFECTION / ALLERGY / INJURY / …). */
+ type: string;
+ /** Lifecycle (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE). */
+ lifecycle: string;
+ /** ISO onset instant. */
+ onsetAt: string;
+ /** ISO resolution instant, or null while ongoing. */
+ resolvedAt: string | null;
+ }> | null;
}
/** The three persisted score types surfaced in the wellness summary. */
@@ -1324,6 +1347,40 @@ export async function collectDoctorReportData(
labResults = collapsed.length > 0 ? collapsed : null;
}
+ // v1.18.1 P4 — illness / condition episodes overlapping the window. Gated
+ // on the illness module (born-gated opt-in): when off, no read, no section.
+ // An episode overlaps when it began on or before the window end AND is
+ // either still ongoing or resolved on or after the window start. Labels +
+ // lifecycle + dates only — the encrypted note is never selected, so no
+ // free-text leaks into the clinical export.
+ let illnessEpisodes: DoctorReportData["illnessEpisodes"] = null;
+ if (moduleMap.illness !== false) {
+ const episodeRows = await prisma.illnessEpisode.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ onsetAt: { lte: end },
+ OR: [{ resolvedAt: null }, { resolvedAt: { gte: start } }],
+ },
+ orderBy: { onsetAt: "asc" },
+ select: {
+ label: true,
+ type: true,
+ lifecycle: true,
+ onsetAt: true,
+ resolvedAt: true,
+ },
+ });
+ const mapped = episodeRows.map((e) => ({
+ label: e.label,
+ type: e.type,
+ lifecycle: e.lifecycle,
+ onsetAt: e.onsetAt.toISOString(),
+ resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null,
+ }));
+ illnessEpisodes = mapped.length > 0 ? mapped : null;
+ }
+
return {
period: {
days,
@@ -1377,6 +1434,7 @@ export async function collectDoctorReportData(
wellnessScores,
cycle,
labResults,
+ illnessEpisodes,
};
}
diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts
index b3e623b8b..7e158b476 100644
--- a/src/lib/doctor-report-pdf-core.ts
+++ b/src/lib/doctor-report-pdf-core.ts
@@ -1521,6 +1521,65 @@ export function buildDoctorReportPdfDocument(
.finalY + 8;
}
+ // v1.18.1 P4 — illness / condition episodes overlapping the window. Present
+ // only when the illness module is on AND the window held an episode (the
+ // aggregator gates `data.illnessEpisodes`). Labels + lifecycle + dates only;
+ // the encrypted note is never read. A purely retrospective, factual table —
+ // no colour, no severity tint — matching the clinical-document register.
+ if (data.illnessEpisodes && data.illnessEpisodes.length > 0) {
+ y = ensureSpace(y, 6 + 18);
+ doc.setFontSize(14);
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(30, 30, 30);
+ doc.text(t("doctorReport.illnessTitle"), margin, y);
+ y += 6;
+
+ const illnessRows = data.illnessEpisodes.map((ep) => [
+ ep.label,
+ t(`illness.type.${ep.type}`),
+ t(`illness.lifecycle.${ep.lifecycle}`),
+ fmtDate(ep.onsetAt),
+ ep.resolvedAt ? fmtDate(ep.resolvedAt) : t("doctorReport.illnessOngoing"),
+ ]);
+
+ autoTable(doc, {
+ startY: y,
+ head: [
+ [
+ t("doctorReport.illnessColCondition"),
+ t("doctorReport.illnessColType"),
+ t("doctorReport.illnessColLifecycle"),
+ t("doctorReport.illnessColOnset"),
+ t("doctorReport.illnessColResolved"),
+ ],
+ ],
+ body: illnessRows,
+ theme: "grid",
+ styles: {
+ fontSize: 9,
+ cellPadding: 3,
+ textColor: [30, 30, 30],
+ lineColor: [200, 200, 200],
+ lineWidth: 0.3,
+ },
+ headStyles: {
+ fillColor: [245, 245, 245],
+ textColor: [30, 30, 30],
+ fontStyle: "bold",
+ },
+ alternateRowStyles: { fillColor: [252, 252, 252] },
+ margin: {
+ left: margin,
+ right: margin,
+ top: margin,
+ bottom: tableBottomMargin,
+ },
+ });
+ y =
+ (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
+ .finalY + 8;
+ }
+
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
// rendered ONLY when the user explicitly opted in. Clearly labelled and
// flagged as not clinically validated so a physician never mistakes it
diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts
index 3f95a9576..eb6ced1e0 100644
--- a/src/lib/fhir/__tests__/build-bundle.test.ts
+++ b/src/lib/fhir/__tests__/build-bundle.test.ts
@@ -1172,3 +1172,105 @@ describe("buildFhirDocumentBundle", () => {
expect(titles).not.toContain("Observations");
});
});
+
+describe("buildFhirDocumentBundle — illness episodes (v1.18.1 P4)", () => {
+ function conditionsOf(bundle: ReturnType) {
+ return bundle.entry
+ .map((e) => e.resource)
+ .filter((r) => r.resourceType === "Condition");
+ }
+ function encountersOf(bundle: ReturnType) {
+ return bundle.entry
+ .map((e) => e.resource)
+ .filter((r) => r.resourceType === "Encounter");
+ }
+
+ it("emits no Condition / Encounter when there are no episodes", () => {
+ const bundle = buildFhirDocumentBundle(
+ makeData({ illnessEpisodes: null }),
+ { insuranceNumber: null },
+ FIXED_NOW,
+ );
+ expect(conditionsOf(bundle)).toHaveLength(0);
+ expect(encountersOf(bundle)).toHaveLength(0);
+ const composition = bundle.entry[0].resource;
+ const titles =
+ composition.resourceType === "Composition"
+ ? (composition.section ?? []).map((s) => s.title)
+ : [];
+ expect(titles).not.toContain("Conditions");
+ });
+
+ it("maps a resolved episode to a Condition (resolved) + finished Encounter", () => {
+ const bundle = buildFhirDocumentBundle(
+ makeData({
+ illnessEpisodes: [
+ {
+ label: "Erkältung",
+ type: "INFECTION",
+ lifecycle: "ACUTE",
+ onsetAt: "2026-04-01T00:00:00.000Z",
+ resolvedAt: "2026-04-10T00:00:00.000Z",
+ },
+ ],
+ }),
+ { insuranceNumber: null },
+ FIXED_NOW,
+ );
+ const conditions = conditionsOf(bundle);
+ expect(conditions).toHaveLength(1);
+ const c = conditions[0];
+ if (c.resourceType !== "Condition") throw new Error("not a Condition");
+ // The user's label rides code.text; the code is the generic disease root
+ // (never a fabricated diagnosis).
+ expect(c.code.text).toBe("Erkältung");
+ expect(c.code.coding?.[0].code).toBe("64572001");
+ expect(c.onsetDateTime).toBe("2026-04-01T00:00:00.000Z");
+ expect(c.abatementDateTime).toBe("2026-04-10T00:00:00.000Z");
+ expect(c.clinicalStatus?.coding?.[0].code).toBe("resolved");
+ // Patient-reported, not clinician-confirmed.
+ expect(c.verificationStatus?.coding?.[0].code).toBe("unconfirmed");
+
+ const encounters = encountersOf(bundle);
+ expect(encounters).toHaveLength(1);
+ const enc = encounters[0];
+ if (enc.resourceType !== "Encounter") throw new Error("not an Encounter");
+ expect(enc.status).toBe("finished");
+ expect(enc.period?.start).toBe("2026-04-01T00:00:00.000Z");
+ expect(enc.period?.end).toBe("2026-04-10T00:00:00.000Z");
+ expect(enc.reasonReference?.[0].reference).toBe(`Condition/${c.id}`);
+
+ const composition = bundle.entry[0].resource;
+ const titles =
+ composition.resourceType === "Composition"
+ ? (composition.section ?? []).map((s) => s.title)
+ : [];
+ expect(titles).toContain("Conditions");
+ });
+
+ it("maps an ongoing episode to active Condition + in-progress Encounter (no abatement)", () => {
+ const bundle = buildFhirDocumentBundle(
+ makeData({
+ illnessEpisodes: [
+ {
+ label: "Long-COVID",
+ type: "CHRONIC",
+ lifecycle: "CHRONIC_ONGOING",
+ onsetAt: "2026-03-01T00:00:00.000Z",
+ resolvedAt: null,
+ },
+ ],
+ }),
+ { insuranceNumber: null },
+ FIXED_NOW,
+ );
+ const c = conditionsOf(bundle)[0];
+ if (c.resourceType !== "Condition") throw new Error("not a Condition");
+ expect(c.clinicalStatus?.coding?.[0].code).toBe("active");
+ expect(c.abatementDateTime).toBeUndefined();
+ const enc = encountersOf(bundle)[0];
+ if (enc.resourceType !== "Encounter") throw new Error("not an Encounter");
+ expect(enc.status).toBe("in-progress");
+ expect(enc.period?.end).toBeUndefined();
+ });
+});
diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts
index 506a370c6..029765a63 100644
--- a/src/lib/fhir/build-bundle.ts
+++ b/src/lib/fhir/build-bundle.ts
@@ -33,6 +33,7 @@ import {
coverageResource,
observationsFromReportData,
cycleObservationsFromReportData,
+ conditionsFromReportData,
medicationStatementsFromReportData,
medicationAdministrationsFromReportData,
} from "@/lib/fhir/resources";
@@ -84,6 +85,7 @@ export function buildFhirDocumentBundle(
const cycleObservationRefs: FhirReference[] = [];
const medicationRefs: FhirReference[] = [];
const administrationRefs: FhirReference[] = [];
+ const conditionRefs: FhirReference[] = [];
// --- Patient -----------------------------------------------------------
const patient = patientResource(data, identity);
@@ -124,6 +126,20 @@ export function buildFhirDocumentBundle(
});
}
+ // --- Condition + Encounter per illness/condition episode (opt-in) ------
+ // v1.18.1 — present only when the illness module is enabled AND the window
+ // held an episode (the aggregator gates `data.illnessEpisodes`). Each
+ // episode emits a patient-reported Condition (generic SNOMED root, label on
+ // `code.text`) plus a bounding Encounter that references it.
+ const { conditions, encounters } = conditionsFromReportData(data);
+ for (const condition of conditions) {
+ entries.push({ fullUrl: `urn:uuid:${condition.id}`, resource: condition });
+ conditionRefs.push({ reference: `Condition/${condition.id}` });
+ }
+ for (const encounter of encounters) {
+ entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter });
+ }
+
// --- Composition (leading "cover" resource) ----------------------------
// v1.9.0 — when the aggregator capped the administration set, disclose
// it in the narrative so the export is honest: it carries the
@@ -196,6 +212,17 @@ export function buildFhirDocumentBundle(
},
]
: []),
+ // v1.18.1 — opt-in illness/condition section. Only present when the
+ // illness module surfaced episodes. Lists the Condition resources (the
+ // Encounter rides each Condition's reasonReference).
+ ...(conditionRefs.length > 0
+ ? [
+ {
+ title: "Conditions",
+ entry: conditionRefs,
+ },
+ ]
+ : []),
],
};
diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts
index 61cf6c327..a3010f7e5 100644
--- a/src/lib/fhir/resources.ts
+++ b/src/lib/fhir/resources.ts
@@ -51,6 +51,8 @@ import type {
FhirDosage,
FhirPatient,
FhirCoverage,
+ FhirCondition,
+ FhirEncounter,
FhirOrganization,
FhirReference,
} from "@/lib/fhir/types";
@@ -885,6 +887,119 @@ export function cycleObservationsFromReportData(
return observations;
}
+/**
+ * SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept.
+ * HealthLog records a patient-kept condition journal; it is NOT a diagnosing
+ * device and asserts no specific ICD/SNOMED diagnosis. The user's own label
+ * rides `code.text` and the broad class is a `category`; the coded `code`
+ * stays this safe root so a downstream system never reads a fabricated
+ * diagnosis off the export.
+ */
+const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const;
+
+/** R4 `Condition.clinicalStatus` concept for the given resolution state. */
+function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept {
+ return {
+ coding: [
+ {
+ system:
+ "http://terminology.hl7.org/CodeSystem/condition-clinical",
+ code: resolved ? "resolved" : "active",
+ },
+ ],
+ };
+}
+
+/**
+ * v1.18.1 P4 — emit one `Condition` per illness/condition episode plus a
+ * bounding `Encounter`. Absent unless the illness module is enabled AND the
+ * window held an episode (the aggregator only populates `illnessEpisodes`
+ * then). Ids run `condition-1..N` / `encounter-1..N`; the Encounter references
+ * its Condition so a clinician sees the time-boxed episode. No specific
+ * diagnosis is coded — see `DISEASE_SNOMED`.
+ */
+export function conditionsFromReportData(data: DoctorReportData): {
+ conditions: FhirCondition[];
+ encounters: FhirEncounter[];
+} {
+ const episodes = data.illnessEpisodes;
+ if (!episodes || episodes.length === 0) {
+ return { conditions: [], encounters: [] };
+ }
+ const conditions: FhirCondition[] = [];
+ const encounters: FhirEncounter[] = [];
+ let seq = 0;
+ for (const ep of episodes) {
+ seq += 1;
+ const conditionId = `condition-${seq}`;
+ const resolved = ep.resolvedAt !== null;
+ conditions.push({
+ resourceType: "Condition",
+ id: conditionId,
+ clinicalStatus: conditionClinicalStatus(resolved),
+ verificationStatus: {
+ coding: [
+ {
+ system:
+ "http://terminology.hl7.org/CodeSystem/condition-ver-status",
+ // Patient-reported journal entry → "unconfirmed", never a
+ // clinician-confirmed diagnosis.
+ code: "unconfirmed",
+ },
+ ],
+ },
+ category: [
+ {
+ coding: [
+ {
+ system:
+ "http://terminology.hl7.org/CodeSystem/condition-category",
+ code: "problem-list-item",
+ },
+ ],
+ // The journal's broad class as a human-readable category label.
+ text: ep.type,
+ },
+ ],
+ code: {
+ coding: [
+ {
+ system: SNOMED_SYSTEM,
+ code: DISEASE_SNOMED.code,
+ display: DISEASE_SNOMED.display,
+ },
+ ],
+ text: ep.label,
+ },
+ subject: patientRef,
+ onsetDateTime: ep.onsetAt,
+ ...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}),
+ note: [
+ {
+ text: `Self-recorded ${ep.lifecycle.toLowerCase().replace(/_/g, " ")} condition journal entry; patient-reported, not a clinical diagnosis.`,
+ },
+ ],
+ });
+ encounters.push({
+ resourceType: "Encounter",
+ id: `encounter-${seq}`,
+ status: resolved ? "finished" : "in-progress",
+ class: {
+ system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
+ code: "AMB",
+ display: "ambulatory",
+ },
+ subject: patientRef,
+ period: {
+ start: ep.onsetAt,
+ ...(ep.resolvedAt ? { end: ep.resolvedAt } : {}),
+ },
+ reasonReference: [{ reference: `Condition/${conditionId}` }],
+ });
+ }
+ return { conditions, encounters };
+}
+
/**
* Emit one `MedicationStatement` per active medication. v1.9.0 — additive
* ATC (primary) / RxNorm (secondary) codings when the user stored them;
diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts
index 883606b52..303d327d7 100644
--- a/src/lib/fhir/types.ts
+++ b/src/lib/fhir/types.ts
@@ -206,6 +206,45 @@ export interface FhirComposition {
section?: FhirCompositionSection[];
}
+/**
+ * v1.18.1 P4 — R4 `Condition`. Carries the illness/condition episode the
+ * journal records: a `code` (SNOMED 64572001 "Disease" as the safe non-
+ * diagnostic root + a `.text` label), the clinical + verification status, an
+ * onset, and (when resolved) an `abatementDateTime`. HealthLog does NOT assert
+ * a specific ICD/SNOMED diagnosis code — the journal is patient-recorded and
+ * not a diagnosing device — so the coding stays the generic root and the
+ * user's label rides `code.text`.
+ */
+export interface FhirCondition {
+ resourceType: "Condition";
+ id: string;
+ clinicalStatus?: FhirCodeableConcept;
+ verificationStatus?: FhirCodeableConcept;
+ category?: FhirCodeableConcept[];
+ code: FhirCodeableConcept;
+ subject: FhirReference;
+ onsetDateTime?: string;
+ abatementDateTime?: string;
+ recordedDate?: string;
+ note?: FhirAnnotation[];
+}
+
+/**
+ * v1.18.1 P4 — R4 `Encounter`. Emitted alongside a resolved episode to bound
+ * the period of care (onset → resolution) so a clinician sees the episode as a
+ * time-boxed event. `status` is `finished` for a resolved episode and
+ * `in-progress` for an ongoing one; `class` is the ambulatory code.
+ */
+export interface FhirEncounter {
+ resourceType: "Encounter";
+ id: string;
+ status: "in-progress" | "finished";
+ class: FhirCoding;
+ subject: FhirReference;
+ period?: FhirPeriod;
+ reasonReference?: FhirReference[];
+}
+
export type FhirResource =
| FhirComposition
| FhirPatient
@@ -213,6 +252,8 @@ export type FhirResource =
| FhirObservation
| FhirMedicationStatement
| FhirMedicationAdministration
+ | FhirCondition
+ | FhirEncounter
| FhirDiagnosticReport;
export interface FhirBundleEntry {
diff --git a/src/lib/gamification/__tests__/achievements.test.ts b/src/lib/gamification/__tests__/achievements.test.ts
index afe2e7fe1..45a4e7afd 100644
--- a/src/lib/gamification/__tests__/achievements.test.ts
+++ b/src/lib/gamification/__tests__/achievements.test.ts
@@ -3,6 +3,7 @@ import {
ACHIEVEMENT_DEFINITIONS,
ACHIEVEMENT_CATEGORY_ORDER,
applyDiscoveryFilter,
+ bridgeFrozenStreakGaps,
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
getUniqueBerlinDays,
@@ -198,3 +199,43 @@ describe("applyDiscoveryFilter", () => {
expect(result.summary.unlockedCount).toBe(0);
});
});
+
+// v1.18.1 P4 — Rest Mode streak-freeze. A streak that lapses ONLY across
+// illness days bridges into one continuous run rather than breaking; it is
+// never invented, and a non-illness gap still breaks the streak.
+describe("bridgeFrozenStreakGaps (Rest Mode streak-freeze)", () => {
+ it("is a no-op with no frozen days (returns the sorted input)", () => {
+ const days = ["2026-06-03", "2026-06-01", "2026-06-02"];
+ expect(bridgeFrozenStreakGaps(days, new Set())).toEqual([
+ "2026-06-01",
+ "2026-06-02",
+ "2026-06-03",
+ ]);
+ });
+
+ it("bridges a gap whose every interior day is frozen, preserving the streak", () => {
+ // Tracked 06-01, then ill 06-02..06-04, tracked again 06-05. Without the
+ // freeze the streak would break to 1; with it, the run is continuous.
+ const tracked = ["2026-06-01", "2026-06-05"];
+ const frozen = new Set(["2026-06-02", "2026-06-03", "2026-06-04"]);
+ const bridged = bridgeFrozenStreakGaps(tracked, frozen);
+ expect(calculateLongestStreak(bridged)).toBe(5);
+ });
+
+ it("does NOT bridge a gap that contains a non-frozen missed day (genuine break)", () => {
+ const tracked = ["2026-06-01", "2026-06-05"];
+ // 06-03 is NOT a frozen illness day → the gap is a real lapse.
+ const frozen = new Set(["2026-06-02", "2026-06-04"]);
+ const bridged = bridgeFrozenStreakGaps(tracked, frozen);
+ expect(calculateLongestStreak(bridged)).toBe(1);
+ });
+
+ it("never invents a streak from a lone frozen day with no surrounding run", () => {
+ const tracked = ["2026-06-10"];
+ const frozen = new Set(["2026-06-02", "2026-06-03"]);
+ const bridged = bridgeFrozenStreakGaps(tracked, frozen);
+ // A single tracked day, untouched — the frozen days bridge nothing.
+ expect(bridged).toEqual(["2026-06-10"]);
+ expect(calculateLongestStreak(bridged)).toBe(1);
+ });
+});
diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts
index a4a7ae47e..227f96761 100644
--- a/src/lib/gamification/achievements.ts
+++ b/src/lib/gamification/achievements.ts
@@ -791,6 +791,68 @@ export function calculateLongestStreak(dayKeys: string[]): number {
return maxStreak;
}
+/**
+ * v1.18.1 P4 — freeze (don't break) a day-streak across illness days.
+ *
+ * Rest Mode principle: a streak that lapses ONLY because the user was unwell
+ * should not be broken — being ill is not a failure to track. This pure
+ * helper bridges any calendar gap between two qualifying days when EVERY
+ * missing day in the gap is a frozen (active-illness) day. The bridged days
+ * are folded into the series so `calculateLongestStreak` sees one continuous
+ * run across the episode.
+ *
+ * It never INVENTS a streak: a frozen day with no surrounding qualifying days
+ * on either side adds nothing (there is no run to preserve), and a gap that
+ * contains even one non-frozen missed day stays a genuine break. The user is
+ * neither penalised for being ill nor rewarded for it — the streak simply
+ * pauses and resumes.
+ *
+ * Both inputs are `YYYY-MM-DD` keys. Returns a fresh ascending, de-duplicated
+ * day-key array; the original is not mutated. A no-op (returns the sorted
+ * input) when `frozenDayKeys` is empty.
+ */
+export function bridgeFrozenStreakGaps(
+ dayKeys: string[],
+ frozenDayKeys: ReadonlySet,
+): string[] {
+ const unique = Array.from(new Set(dayKeys)).sort();
+ if (frozenDayKeys.size === 0 || unique.length < 2) return unique;
+
+ const frozenSerials = new Set();
+ for (const key of frozenDayKeys) frozenSerials.add(dayKeyToNumber(key));
+
+ const bridged = new Set(unique);
+ for (let i = 1; i < unique.length; i++) {
+ const prev = dayKeyToNumber(unique[i - 1]);
+ const current = dayKeyToNumber(unique[i]);
+ if (current - prev <= 1) continue;
+ // A gap. Bridge it only when every interior day is a frozen illness day.
+ let allFrozen = true;
+ for (let serial = prev + 1; serial < current; serial++) {
+ if (!frozenSerials.has(serial)) {
+ allFrozen = false;
+ break;
+ }
+ }
+ if (allFrozen) {
+ for (let serial = prev + 1; serial < current; serial++) {
+ bridged.add(serialToDayKey(serial));
+ }
+ }
+ }
+
+ return Array.from(bridged).sort();
+}
+
+/** Inverse of `dayKeyToNumber` — a UTC day serial back to a `YYYY-MM-DD` key. */
+function serialToDayKey(serial: number): string {
+ const d = new Date(serial * 86_400_000);
+ const year = d.getUTCFullYear();
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
function calculateProgress(current: number, target: number): number {
if (target <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / target) * 100)));
diff --git a/src/lib/illness/__tests__/rest-mode.test.ts b/src/lib/illness/__tests__/rest-mode.test.ts
new file mode 100644
index 000000000..2d46ceb0e
--- /dev/null
+++ b/src/lib/illness/__tests__/rest-mode.test.ts
@@ -0,0 +1,117 @@
+/**
+ * v1.18.1 P4 — Rest Mode resolver.
+ *
+ * Rest Mode ANNOTATES, never penalises. These tests pin the resolver
+ * contract: module-gated (a disabled / opted-out account is never in Rest
+ * Mode and does no episode read), the fold to the renderable context, and the
+ * fail-soft behaviour (a read error reads as "not in Rest Mode" so a Rest Mode
+ * annotation can never break the payload it rides on).
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const gate = vi.hoisted(() => ({ isIllnessEnabled: vi.fn() }));
+const db = vi.hoisted(() => ({
+ prisma: { illnessEpisode: { findMany: vi.fn() } },
+}));
+
+vi.mock("@/lib/illness/gate", () => gate);
+vi.mock("@/lib/db", () => db);
+
+import {
+ getActiveIllnessEpisodes,
+ resolveRestMode,
+ toRestModeContext,
+ REST_MODE_INACTIVE,
+} from "@/lib/illness/rest-mode";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("getActiveIllnessEpisodes", () => {
+ it("returns [] without a read when the illness module is disabled", async () => {
+ gate.isIllnessEnabled.mockResolvedValue(false);
+ await expect(getActiveIllnessEpisodes("u1")).resolves.toEqual([]);
+ expect(db.prisma.illnessEpisode.findMany).not.toHaveBeenCalled();
+ });
+
+ it("queries only active (unresolved, not-deleted, onset ≤ asOf) episodes when enabled", async () => {
+ gate.isIllnessEnabled.mockResolvedValue(true);
+ db.prisma.illnessEpisode.findMany.mockResolvedValue([]);
+ const asOf = new Date("2026-06-16T00:00:00.000Z");
+ await getActiveIllnessEpisodes("u1", asOf);
+ const arg = db.prisma.illnessEpisode.findMany.mock.calls[0][0];
+ expect(arg.where).toMatchObject({
+ userId: "u1",
+ deletedAt: null,
+ resolvedAt: null,
+ onsetAt: { lte: asOf },
+ });
+ });
+});
+
+describe("toRestModeContext", () => {
+ it("folds an empty set to the inactive context", () => {
+ expect(toRestModeContext([])).toEqual(REST_MODE_INACTIVE);
+ });
+
+ it("anchors `since` on the earliest onset and counts episodes (no note leaks)", () => {
+ const ctx = toRestModeContext([
+ {
+ id: "e1",
+ label: "Erkältung",
+ type: "INFECTION",
+ lifecycle: "ACUTE",
+ onsetAt: new Date("2026-06-10T00:00:00.000Z"),
+ },
+ {
+ id: "e2",
+ label: "Heuschnupfen",
+ type: "ALLERGY",
+ lifecycle: "RECURRING",
+ onsetAt: new Date("2026-06-14T00:00:00.000Z"),
+ },
+ ]);
+ expect(ctx.active).toBe(true);
+ expect(ctx.episodeCount).toBe(2);
+ expect(ctx.since).toBe("2026-06-10T00:00:00.000Z");
+ // The context carries labels + lifecycle only — no free-text note field.
+ expect(ctx.episodes[0]).not.toHaveProperty("note");
+ expect(Object.keys(ctx.episodes[0]).sort()).toEqual([
+ "id",
+ "label",
+ "lifecycle",
+ "onsetAt",
+ "type",
+ ]);
+ });
+});
+
+describe("resolveRestMode", () => {
+ it("resolves active context from the active episodes", async () => {
+ gate.isIllnessEnabled.mockResolvedValue(true);
+ db.prisma.illnessEpisode.findMany.mockResolvedValue([
+ {
+ id: "e1",
+ label: "Erkältung",
+ type: "INFECTION",
+ lifecycle: "ACUTE",
+ onsetAt: new Date("2026-06-10T00:00:00.000Z"),
+ },
+ ]);
+ const ctx = await resolveRestMode("u1");
+ expect(ctx.active).toBe(true);
+ expect(ctx.episodeCount).toBe(1);
+ });
+
+ it("is fail-soft: a read error reads as not-in-Rest-Mode", async () => {
+ gate.isIllnessEnabled.mockResolvedValue(true);
+ db.prisma.illnessEpisode.findMany.mockRejectedValue(new Error("boom"));
+ await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE);
+ });
+
+ it("is inactive (no read) for a disabled module", async () => {
+ gate.isIllnessEnabled.mockResolvedValue(false);
+ await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE);
+ });
+});
diff --git a/src/lib/illness/rest-mode.ts b/src/lib/illness/rest-mode.ts
new file mode 100644
index 000000000..66e7a38cb
--- /dev/null
+++ b/src/lib/illness/rest-mode.ts
@@ -0,0 +1,137 @@
+/**
+ * Rest Mode — the server-authoritative "an episode is active" 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.
+ *
+ * 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
+ * the same fact. It is server-authoritative: the resolved `RestModeContext`
+ * is the DTO the iOS client mirrors (it renders Rest Mode, never recomputes
+ * 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.)
+ */
+import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client";
+import { prisma as defaultPrisma } from "@/lib/db";
+import { isIllnessEnabled } from "@/lib/illness/gate";
+
+/** Minimal shape the Rest Mode resolver needs from an episode row. */
+export type ActiveIllnessEpisode = Pick<
+ IllnessEpisode,
+ "id" | "label" | "type" | "lifecycle" | "onsetAt"
+>;
+
+/**
+ * The resolved Rest Mode annotation a surface attaches to its result. Carries
+ * NO measured value — it is pure narrative context. `active` is the single
+ * flag a renderer branches on; `since` is the earliest active onset (the
+ * "unwell since" anchor); `episodes` is the small set backing it (label +
+ * type only — no decrypted note, no free-text leaks into a score payload).
+ *
+ * This is the server-authoritative shape iOS mirrors verbatim.
+ */
+export interface RestModeContext {
+ /** True when ≥ 1 illness episode is active as of the reference instant. */
+ active: boolean;
+ /** ISO onset of the earliest active episode, or null when none. */
+ since: string | null;
+ /** Count of active episodes (usually 1; ≥ 2 when conditions overlap). */
+ episodeCount: number;
+ /** The active episodes, label + type + onset only — never a decrypted note. */
+ episodes: Array<{
+ id: string;
+ label: string;
+ type: string;
+ lifecycle: string;
+ onsetAt: string;
+ }>;
+}
+
+/** The neutral, honest empty state — not in Rest Mode. */
+export const REST_MODE_INACTIVE: RestModeContext = {
+ active: false,
+ since: null,
+ episodeCount: 0,
+ episodes: [],
+};
+
+/**
+ * Read the active illness episodes for an account as of `asOf`. 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
+ * the scored day".
+ */
+export async function getActiveIllnessEpisodes(
+ userId: string,
+ asOf: Date = new Date(),
+ client: PrismaClient = defaultPrisma,
+): Promise {
+ if (!(await isIllnessEnabled(userId))) return [];
+ return client.illnessEpisode.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ resolvedAt: null,
+ onsetAt: { lte: asOf },
+ },
+ select: {
+ id: true,
+ label: true,
+ type: true,
+ lifecycle: true,
+ onsetAt: true,
+ },
+ orderBy: { onsetAt: "asc" },
+ });
+}
+
+/** Fold a set of active episodes into the renderable Rest Mode context. Pure. */
+export function toRestModeContext(
+ episodes: ActiveIllnessEpisode[],
+): RestModeContext {
+ if (episodes.length === 0) return REST_MODE_INACTIVE;
+ return {
+ active: true,
+ since: episodes[0].onsetAt.toISOString(),
+ episodeCount: episodes.length,
+ episodes: episodes.map((e) => ({
+ id: e.id,
+ label: e.label,
+ type: e.type,
+ lifecycle: e.lifecycle,
+ onsetAt: e.onsetAt.toISOString(),
+ })),
+ };
+}
+
+/**
+ * Resolve the Rest Mode context for an account as of `asOf` — the one call a
+ * surface makes to learn whether to annotate. Module-gated and fail-soft: any
+ * resolution error reads as "not in Rest Mode" so a Rest Mode read can never
+ * break the score / recovery / achievements payload it annotates.
+ */
+export async function resolveRestMode(
+ userId: string,
+ asOf: Date = new Date(),
+ client: PrismaClient = defaultPrisma,
+): Promise {
+ try {
+ const episodes = await getActiveIllnessEpisodes(userId, asOf, client);
+ return toRestModeContext(episodes);
+ } catch {
+ return REST_MODE_INACTIVE;
+ }
+}
diff --git a/src/lib/insights/__tests__/recovery-score.test.ts b/src/lib/insights/__tests__/recovery-score.test.ts
index ac2b39a51..d54b7781a 100644
--- a/src/lib/insights/__tests__/recovery-score.test.ts
+++ b/src/lib/insights/__tests__/recovery-score.test.ts
@@ -19,11 +19,25 @@ vi.mock("@/lib/insights/derived/readiness", () => ({
computeReadiness: (...args: unknown[]) => computeReadinessMock(...args),
}));
+// v1.18.1 P4 — Rest Mode resolver. Default inactive; the annotation test
+// flips it active to assert the score is NOT penalised, only annotated.
+const restModeMock = vi.hoisted(() => ({
+ resolveRestMode: vi.fn(async () => ({
+ active: false as boolean,
+ since: null as string | null,
+ episodeCount: 0,
+ episodes: [] as unknown[],
+ })),
+}));
+vi.mock("@/lib/illness/rest-mode", () => restModeMock);
+
import {
recoveryDayKey,
recoveryExternalId,
recoveryMeasuredAt,
+ computeRecoveryScore,
persistRecoveryScore,
+ loadRecoveryProfile,
RECOVERY_SCORE_EXTERNAL_ID_PREFIX,
} from "../recovery-score";
@@ -65,6 +79,12 @@ function makePrisma() {
beforeEach(() => {
vi.clearAllMocks();
+ restModeMock.resolveRestMode.mockResolvedValue({
+ active: false,
+ since: null,
+ episodeCount: 0,
+ episodes: [],
+ });
});
describe("recovery-score helpers", () => {
@@ -83,6 +103,41 @@ describe("recovery-score helpers", () => {
});
});
+describe("computeRecoveryScore — Rest Mode annotation (v1.18.1 P4)", () => {
+ const profile = {} as Parameters[2];
+
+ it("annotates an active episode WITHOUT penalising the score", async () => {
+ // A genuinely low recovery blend (40) — the kind a low day produces.
+ computeReadinessMock.mockResolvedValue(okReadiness(40));
+ restModeMock.resolveRestMode.mockResolvedValue({
+ active: true,
+ since: "2026-05-30T00:00:00.000Z",
+ episodeCount: 1,
+ episodes: [],
+ });
+ const { prisma } = makePrisma();
+ const result = await computeRecoveryScore(prisma, "u1", profile, NOW);
+ // The raw blend is reported verbatim — Rest Mode does not adjust it.
+ expect(result.score).toBe(40);
+ // …but the context is attached so the surface can frame it.
+ expect(result.restMode.active).toBe(true);
+ expect(result.restMode.since).toBe("2026-05-30T00:00:00.000Z");
+ });
+
+ it("carries the inactive context when no episode is active", async () => {
+ computeReadinessMock.mockResolvedValue(okReadiness(80));
+ const { prisma } = makePrisma();
+ const result = await computeRecoveryScore(prisma, "u1", profile, NOW);
+ expect(result.score).toBe(80);
+ expect(result.restMode.active).toBe(false);
+ });
+
+ // Keep `loadRecoveryProfile` referenced so the named import stays exercised.
+ it("exposes loadRecoveryProfile", () => {
+ expect(typeof loadRecoveryProfile).toBe("function");
+ });
+});
+
describe("persistRecoveryScore", () => {
it("stores a COMPUTED RECOVERY_SCORE row when the blend is ok", async () => {
const { prisma, upsert } = makePrisma();
diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts
index 5ef846c60..ca6e9c593 100644
--- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts
+++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts
@@ -9,6 +9,16 @@ 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.
+const restModeMock = vi.hoisted(() => ({
+ resolveRestMode: vi.fn(async () => ({
+ active: false as boolean,
+ since: null as string | null,
+ episodeCount: 0,
+ episodes: [] as unknown[],
+ })),
+}));
+vi.mock("@/lib/illness/rest-mode", () => restModeMock);
import { prisma } from "@/lib/db";
import {
@@ -24,8 +34,40 @@ const findMany = prisma.measurement.findMany as ReturnType;
beforeEach(() => {
vi.clearAllMocks();
findMany.mockResolvedValue([]);
+ restModeMock.resolveRestMode.mockResolvedValue({
+ active: false,
+ since: null,
+ episodeCount: 0,
+ episodes: [],
+ });
});
+/** RHR + HRV each flat, then a final day that pushes both outside the band. */
+function firingRows(args: { where: { type: string } }) {
+ const baseline = (base: number, outlier: number) => [
+ ...Array.from({ length: 9 }, (_, i) => ({
+ value: base,
+ measuredAt: new Date(`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`),
+ })),
+ { value: outlier, measuredAt: new Date("2026-06-02T07:00:00Z") },
+ ];
+ if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95);
+ if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10);
+ return [];
+}
+
+/** RHR + HRV each flat (banded but inside) — the no-fire-but-banded case. */
+function quietBandedRows(args: { where: { type: string } }) {
+ const flat = (base: number) =>
+ Array.from({ length: 10 }, (_, i) => ({
+ value: base,
+ measuredAt: new Date(`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`),
+ }));
+ if (args.where.type === "RESTING_HEART_RATE") return flat(58);
+ if (args.where.type === "HEART_RATE_VARIABILITY") return flat(60);
+ return [];
+}
+
describe("classifyDeviation", () => {
it("flags above / below / in-band", () => {
expect(classifyDeviation("PULSE", 90, 60, 80, 70).direction).toBe("above");
@@ -105,4 +147,33 @@ describe("computeCoincidentDeviation", () => {
expect(result.value.fired).toBe(false);
}
});
+
+ 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: [],
+ });
+ findMany.mockImplementation(firingRows);
+ const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW });
+ expect(result.status).toBe("ok");
+ if (result.status === "ok") {
+ expect(result.value.fired).toBe(true);
+ // Reframed — but the measured deviations are untouched.
+ expect(result.value.illnessExplained).toBe(true);
+ expect(result.value.contributing.length).toBeGreaterThanOrEqual(
+ COINCIDENT_FIRE_THRESHOLD,
+ );
+ }
+ });
+
+ it("does not resolve Rest Mode (no reframe) when the flag did not fire", async () => {
+ // Banded but inside-band: the reframe must not even query Rest Mode.
+ findMany.mockImplementation(quietBandedRows);
+ const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW });
+ expect(result.status === "ok" && result.value.fired).toBe(false);
+ expect(result.status === "ok" && result.value.illnessExplained).toBe(false);
+ expect(restModeMock.resolveRestMode).not.toHaveBeenCalled();
+ });
});
diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts
index e347f975b..cc26a653c 100644
--- a/src/lib/insights/derived/coincident-deviation.ts
+++ b/src/lib/insights/derived/coincident-deviation.ts
@@ -39,6 +39,7 @@ import {
import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
+import { resolveRestMode } from "@/lib/illness/rest-mode";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DEFAULT_WINDOW_DAYS = 30;
@@ -78,6 +79,16 @@ export interface CoincidentDeviationValue {
contributing: VitalDeviation[];
/** The day the flag was evaluated (YYYY-MM-DD). */
day: string;
+ /**
+ * v1.18.1 P4 — Rest Mode reframe. True when the flag fired AND an
+ * illness/condition episode is active: the deviations have a known
+ * explanation (the user is unwell), so the surface frames them as
+ * illness-explained — "your vitals are off because you're ill" — instead of
+ * presenting them as an unexplained anomaly. The vital numbers themselves
+ * are unchanged; only the framing differs. Resolved server-side; iOS
+ * mirrors it.
+ */
+ illnessExplained: boolean;
}
// ── pure classifier (exported for tests) ───────────────────────────────
@@ -216,6 +227,13 @@ export async function computeCoincidentDeviation(
const contributing = vitals.filter((v) => v.outside);
const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD;
+ // 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.
+ const illnessExplained = fired
+ ? (await resolveRestMode(userId, now)).active
+ : false;
+
const { coverage: cov, confidence } = deriveCoverage({
// The coverage axis is "how many vitals could have coincided".
requiredInputs: vitals.length,
@@ -236,6 +254,7 @@ export async function computeCoincidentDeviation(
vitals,
contributing,
day: latestDay,
+ illnessExplained,
},
coverage: cov,
confidence,
diff --git a/src/lib/insights/recovery-score.ts b/src/lib/insights/recovery-score.ts
index 40db18a88..ec3cff2cd 100644
--- a/src/lib/insights/recovery-score.ts
+++ b/src/lib/insights/recovery-score.ts
@@ -51,6 +51,7 @@ import {
upsertScoreRow,
} from "@/lib/insights/score-row";
import type { Derived } from "@/lib/insights/derived/types";
+import { resolveRestMode, type RestModeContext } from "@/lib/illness/rest-mode";
/** The per-day idempotency-key prefix for a stored Recovery score row. */
export const RECOVERY_SCORE_EXTERNAL_ID_PREFIX = "recovery:";
@@ -80,6 +81,14 @@ export interface RecoveryComputeResult {
readiness: Derived;
/** The 0..100 score to persist, or null when the blend was insufficient. */
score: number | null;
+ /**
+ * v1.18.1 P4 — Rest Mode context as of the scored day. ANNOTATES, never
+ * penalises: the `score` above is the raw recovery blend, unchanged when an
+ * illness episode is active. This context lets the recovery surface frame a
+ * low recovery score as "expected while you're unwell" rather than reading
+ * it as an unexplained drop. Resolved server-side; iOS mirrors it.
+ */
+ restMode: RestModeContext;
}
/**
@@ -100,7 +109,11 @@ export async function computeRecoveryScore(
// the result is deterministic for tests.
const readiness = await computeReadiness(userId, profile, { now });
const score = readiness.status === "ok" ? readiness.value.score : null;
- return { readiness, score };
+ // Rest Mode context as of the scored day — annotation only, never folded
+ // into `score`. An active episode frames a low recovery number; it does not
+ // adjust it.
+ const restMode = await resolveRestMode(userId, now, prisma);
+ return { readiness, score, restMode };
}
/**
diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts
index 64378ebef..a417e359b 100644
--- a/src/lib/jobs/__tests__/coach-nudge.test.ts
+++ b/src/lib/jobs/__tests__/coach-nudge.test.ts
@@ -52,6 +52,17 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
+// v1.18.1 P4 — Rest Mode pause. Default: not in Rest Mode (every existing
+// gate/trigger test stays unaffected). The pause test overrides it active.
+const restModeMock = vi.hoisted(() => ({
+ resolveRestMode: vi.fn(async () => ({
+ active: false as boolean,
+ since: null as string | null,
+ episodeCount: 0,
+ episodes: [] as unknown[],
+ })),
+}));
+vi.mock("@/lib/illness/rest-mode", () => restModeMock);
function dose(taken: boolean, skipped = false, autoMissed?: boolean) {
return {
@@ -560,6 +571,12 @@ describe("runCoachNudgeTick — gates and prefs", () => {
coach: true,
} as Awaited>);
vi.mocked(userRowHasProviderCredential).mockReturnValue(true);
+ restModeMock.resolveRestMode.mockResolvedValue({
+ active: false,
+ since: null,
+ episodeCount: 0,
+ episodes: [],
+ });
});
it("counts a master opt-out before touching provider or cap gates", async () => {
@@ -646,6 +663,32 @@ describe("runCoachNudgeTick — gates and prefs", () => {
expect(summary.skippedNoTrigger).toBe(1);
});
+ it("pauses the cadence-nudge during an active illness episode (Rest Mode)", async () => {
+ // A user who would otherwise fire a compliance nudge (17 % adherence).
+ const dispatch = vi.fn();
+ restModeMock.resolveRestMode.mockResolvedValue({
+ active: true,
+ since: "2026-06-10T00:00:00.000Z",
+ episodeCount: 1,
+ episodes: [],
+ });
+ const prisma = prismaMock({
+ users: [userRow(null)],
+ intakeRows: failingIntakes,
+ });
+ const summary = await runCoachNudgeTick(
+ prisma as unknown as PrismaClient,
+ now,
+ { dispatch },
+ );
+ // The nudge is paused, not penalised: no dispatch, no trigger search even
+ // ran (the Rest Mode gate sits before the trigger query).
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
+ expect(summary.skippedDuringIllness).toBe(1);
+ expect(summary.dispatched).toBe(0);
+ });
+
it("personalises the dispatched body with the decrypted Coach focus", async () => {
const dispatch = vi.fn(async (input: unknown) => {
void input;
diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts
index baea8459a..8c5ecb03f 100644
--- a/src/lib/jobs/coach-nudge.ts
+++ b/src/lib/jobs/coach-nudge.ts
@@ -78,6 +78,7 @@ import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { getEvent } from "@/lib/logging/context";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
+import { resolveRestMode } from "@/lib/illness/rest-mode";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -171,6 +172,14 @@ export interface CoachNudgeSummary {
skippedRecentNudge: number;
skippedNoTrigger: number;
skippedNoChannel: number;
+ /**
+ * v1.18.1 P4 — Rest Mode pause: a cadence-nudge ("weigh more often",
+ * "measure BP morning + evening") is the wrong message to a user who is
+ * actively unwell. While an illness episode is active the nudge is paused,
+ * not penalised: nothing is recorded against the user, the cadence simply
+ * resumes once the episode resolves.
+ */
+ skippedDuringIllness: number;
failed: number;
}
@@ -698,6 +707,7 @@ export async function runCoachNudgeTick(
skippedRecentNudge: 0,
skippedNoTrigger: 0,
skippedNoChannel: 0,
+ skippedDuringIllness: 0,
failed: 0,
};
@@ -782,6 +792,18 @@ export async function runCoachNudgeTick(
continue;
}
+ // Gate 6 — 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
+ // (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.
+ const restMode = await resolveRestMode(user.id, now, prisma);
+ if (restMode.active) {
+ summary.skippedDuringIllness += 1;
+ continue;
+ }
+
const trigger = await findTriggerForUser(
prisma,
user,