mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user