merge: suppress-during-illness Rest Mode + FHIR Condition + doctor-report

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 22:27:13 +02:00
commit b28fa4d4a5
37 files changed
+1352 -24

No files matched your search

+10 -2
View File
@@ -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",
+10 -2
View File
@@ -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",
+10 -2
View File
@@ -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",
+10 -2
View File
@@ -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",
+10 -2
View File
@@ -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",
+10 -2
View File
@@ -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",
+16
View File
@@ -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,
@@ -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",
@@ -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,
);
@@ -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,
);
@@ -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,
);
+61 -8
View File
@@ -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<string> {
const frozen = new Set<string>();
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<Record<string, Date>> = {};
@@ -41,13 +41,16 @@ function deviation(
}
function ok(
value: CoincidentDeviationValue,
value: Omit<CoincidentDeviationValue, "illnessExplained"> &
Partial<Pick<CoincidentDeviationValue, "illnessExplained">>,
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("<CoincidentDeviationCard>", () => {
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(<CoincidentDeviationCard />);
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(
@@ -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")}
</p>
</CardShell>
);
@@ -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");
});
});
@@ -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 () => []) },
},
}));
@@ -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 () => []) },
},
}));
@@ -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 () => []) },
},
}));
+84
View File
@@ -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<CoachIllnessBlock | null> {
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(),
})),
};
}
+18
View File
@@ -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");
}
+21
View File
@@ -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 = {
+58
View File
@@ -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,
};
}
+59
View File
@@ -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
+102
View File
@@ -1172,3 +1172,105 @@ describe("buildFhirDocumentBundle", () => {
expect(titles).not.toContain("Observations");
});
});
describe("buildFhirDocumentBundle — illness episodes (v1.18.1 P4)", () => {
function conditionsOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
return bundle.entry
.map((e) => e.resource)
.filter((r) => r.resourceType === "Condition");
}
function encountersOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
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();
});
});
+27
View File
@@ -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,
},
]
: []),
],
};
+115
View File
@@ -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;
+41
View File
@@ -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 {
@@ -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);
});
});
+62
View File
@@ -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>,
): string[] {
const unique = Array.from(new Set(dayKeys)).sort();
if (frozenDayKeys.size === 0 || unique.length < 2) return unique;
const frozenSerials = new Set<number>();
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)));
+117
View File
@@ -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);
});
});
+137
View File
@@ -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<ActiveIllnessEpisode[]> {
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<RestModeContext> {
try {
const episodes = await getActiveIllnessEpisodes(userId, asOf, client);
return toRestModeContext(episodes);
} catch {
return REST_MODE_INACTIVE;
}
}
@@ -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<typeof computeRecoveryScore>[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();
@@ -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<typeof vi.fn>;
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();
});
});
@@ -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,
+14 -1
View File
@@ -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<ReadinessValue>;
/** 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 };
}
/**
@@ -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<ReturnType<typeof getAssistantFlags>>);
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;
+22
View File
@@ -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,