feat(illness): Rest Mode — annotate score/recovery/streaks during an active episode + FHIR/doctor-report

An active illness/condition episode annotates, it never penalises: the
measured number is untouched, only the narrative changes.

- rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft)
  for "is the account in Rest Mode + which episodes back it". iOS mirrors the
  resolved context, never recomputes the suppression.
- health-score: /api/analytics attaches a value-free Rest Mode annotation;
  computeHealthScore stays pure (score never adjusted).
- recovery-score: computeRecoveryScore carries the Rest Mode context as of the
  scored day; the persisted blend is unchanged.
- coach-nudge: pause cadence-nudges during an active episode (new
  skippedDuringIllness counter, gate sits before the trigger search).
- achievements: bridgeFrozenStreakGaps freezes (does not break) every
  day-streak across illness days; never invents a streak, a non-illness gap
  still breaks.
- coincident-deviation: reframe a fired flag as illness-explained; vital
  numbers untouched. Surfaced as a neutral line on the insights card.
- coach snapshot: illness history + active illness as load-bearing context so
  the coach knows about Rest Mode (no decrypted note reaches the prompt).
- FHIR: illness episodes map to Condition (generic disease root, label on
  code.text, patient-reported/unconfirmed) + a bounding Encounter.
- doctor-report: module-gated illness section in the aggregator + PDF table
  (retrospective, neutral palette); FHIR Condition section in the bundle.

i18n x6 for the report + reframe copy. Tests: score-annotation-not-
penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter
mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test
green, openapi in sync, knip clean.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 22:25:50 +02:00
1 parent 8447ad90cc
commit c59cf0b652
37 files changed
+1352 -24

No files matched your search

@@ -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>
);