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

+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;
}
}