mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge: suppress-during-illness Rest Mode + FHIR Condition + doctor-report
This commit is contained in:
commit
b28fa4d4a5
37 files changed
+1352
-24
No files matched your search
+10
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user