From c59cf0b652db58538c2e74576643a04886477db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 22:25:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(illness):=20Rest=20Mode=20=E2=80=94=20anno?= =?UTF-8?q?tate=20score/recovery/streaks=20during=20an=20active=20episode?= =?UTF-8?q?=20+=20FHIR/doctor-report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An active illness/condition episode annotates, it never penalises: the measured number is untouched, only the narrative changes. - rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft) for "is the account in Rest Mode + which episodes back it". iOS mirrors the resolved context, never recomputes the suppression. - health-score: /api/analytics attaches a value-free Rest Mode annotation; computeHealthScore stays pure (score never adjusted). - recovery-score: computeRecoveryScore carries the Rest Mode context as of the scored day; the persisted blend is unchanged. - coach-nudge: pause cadence-nudges during an active episode (new skippedDuringIllness counter, gate sits before the trigger search). - achievements: bridgeFrozenStreakGaps freezes (does not break) every day-streak across illness days; never invents a streak, a non-illness gap still breaks. - coincident-deviation: reframe a fired flag as illness-explained; vital numbers untouched. Surfaced as a neutral line on the insights card. - coach snapshot: illness history + active illness as load-bearing context so the coach knows about Rest Mode (no decrypted note reaches the prompt). - FHIR: illness episodes map to Condition (generic disease root, label on code.text, patient-reported/unconfirmed) + a bounding Encounter. - doctor-report: module-gated illness section in the aggregator + PDF table (retrospective, neutral palette); FHIR Condition section in the bundle. i18n x6 for the report + reframe copy. Tests: score-annotation-not- penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test green, openapi in sync, knip clean. --- messages/de.json | 12 +- messages/en.json | 12 +- messages/es.json | 12 +- messages/fr.json | 12 +- messages/it.json | 12 +- messages/pl.json | 12 +- src/app/api/analytics/route.ts | 16 ++ .../health-record/__tests__/route.test.ts | 2 + .../__tests__/hidden-redaction.test.ts | 2 + .../achievements/__tests__/ios-format.test.ts | 2 + .../__tests__/module-gate.test.ts | 2 + .../api/gamification/achievements/route.ts | 69 ++++++++- .../coincident-deviation-card.test.tsx | 33 ++++- .../insights/coincident-deviation-card.tsx | 8 +- .../__tests__/doctor-report-pdf-core.test.ts | 59 ++++++++ .../coach/__tests__/snapshot-budget.test.ts | 1 + .../__tests__/snapshot-new-metrics.test.ts | 1 + src/lib/ai/coach/__tests__/snapshot.test.ts | 2 + src/lib/ai/coach/illness-snapshot.ts | 84 +++++++++++ src/lib/ai/coach/snapshot.ts | 18 +++ src/lib/analytics/health-score.ts | 21 +++ src/lib/doctor-report-data.ts | 58 ++++++++ src/lib/doctor-report-pdf-core.ts | 59 ++++++++ src/lib/fhir/__tests__/build-bundle.test.ts | 102 +++++++++++++ src/lib/fhir/build-bundle.ts | 27 ++++ src/lib/fhir/resources.ts | 115 +++++++++++++++ src/lib/fhir/types.ts | 41 ++++++ .../__tests__/achievements.test.ts | 41 ++++++ src/lib/gamification/achievements.ts | 62 ++++++++ src/lib/illness/__tests__/rest-mode.test.ts | 117 +++++++++++++++ src/lib/illness/rest-mode.ts | 137 ++++++++++++++++++ .../insights/__tests__/recovery-score.test.ts | 55 +++++++ .../__tests__/coincident-deviation.test.ts | 71 +++++++++ .../insights/derived/coincident-deviation.ts | 19 +++ src/lib/insights/recovery-score.ts | 15 +- src/lib/jobs/__tests__/coach-nudge.test.ts | 43 ++++++ src/lib/jobs/coach-nudge.ts | 22 +++ 37 files changed, 1352 insertions(+), 24 deletions(-) create mode 100644 src/lib/ai/coach/illness-snapshot.ts create mode 100644 src/lib/illness/__tests__/rest-mode.test.ts create mode 100644 src/lib/illness/rest-mode.ts diff --git a/messages/de.json b/messages/de.json index a0665ff79..a52288d12 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 6f6bb9e82..21c263420 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 7fb8cfb38..fdc0aa518 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 f49bfae54..ca423c5b9 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 71172582a..fdfaf6a6c 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 66756a704..d4689d7b5 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,