feat(doctor-report): put emergency data on page one

Add an EMERGENCY leaf to the identity group of the report catalogue,
default-on, no module gate. When the leaf is admitted and the profile
holds emergency data, the aggregator collects it and the PDF renders an
emergency sheet as page one (banner, blood type, severe allergies, active
medications, chronic conditions, implants, advance directive and organ
donor, contacts, notes), then breaks to send the rest of the report to
page two. The section is withheld when the leaf is not admitted or no
data is present. Guards updated for the new leaf counts.
This commit is contained in:
Marc-André Bombeck committed 2026-08-10 05:32:59 +02:00
1 parent 6770bf3b7f
commit 310a9a4690
14 files changed
+311 -17

No files matched your search

@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(93);
expect(ALL_LEAF_IDS.length).toBe(94);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(16);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(17);
});
it("places every leaf in exactly one group", () => {
@@ -187,7 +187,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
// These six carry the standard template's leaves. On a first run they read
// 0/n like every other group: the template is a button, not a starting
// state.
expect(groupChip(html, "identity")).toBe("0/2");
expect(groupChip(html, "identity")).toBe("0/3");
expect(groupChip(html, "vitals")).toBe("0/7");
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
@@ -62,15 +62,16 @@ describe("share-link create form — re-mint seeding", () => {
const html = render(<ShareLinkCreateForm />);
expect(html).toContain('data-testid="report-scope-picker-share"');
expect(html).toContain('data-testid="report-group-row-identity-share"');
// The server refuses the leaf with a 422; a control that cannot be
// honoured has no business rendering, so the identity group is one leaf
// wide here and two wide in the export panel. Nothing in it is on: a new
// link starts with an empty scope on every surface.
// The server refuses the insurance leaf with a 422; a control that cannot
// be honoured has no business rendering, so the identity group is two
// leaves wide here (patient identity + emergency) and three wide in the
// export panel. Nothing in it is on: a new link starts with an empty scope
// on every surface.
const row = html.match(
/data-testid="report-group-row-identity-share"[\s\S]*?<\/div>/,
)?.[0];
expect(row).toBeDefined();
expect(row).toContain(">0/1<");
expect(row).toContain(">0/2<");
// The fenced tier renders its leaves unconditionally; none is checked.
expect(html).toContain('data-testid="report-leaf-MOOD-share"');
expect(html).not.toContain('data-testid="report-leaf-INSURANCE-share"');
@@ -591,6 +591,51 @@ describe("doctor-report illness section", () => {
});
});
// ── emergency first page ── the Notfalldaten sheet owns page one, but only
// when the aggregator populated `data.emergency` (the EMERGENCY leaf admitted
// AND the profile holds something). A null payload — which is exactly what the
// aggregator returns when the leaf is NOT admitted — withholds the page.
describe("doctor-report emergency first page", () => {
const EMERGENCY: NonNullable<DoctorReportData["emergency"]> = {
bloodType: "O_NEG",
organDonor: "YES",
advanceDirective: "EXISTS",
contacts: "ICE contact on the recorded number",
contactsUnreadable: false,
implants: null,
implantsUnreadable: false,
note: null,
noteUnreadable: false,
};
it("prints the emergency sheet on page one when emergency data is present", async () => {
const { t } = getServerTranslator("en");
const data = makeData({ emergency: EMERGENCY });
const options = { t, locale: "en" as const, now: FIXED_NOW };
const text = await extractText(renderDoctorReportPdfBytes(data, options));
expect(text).toContain("Emergency information");
expect(text).toContain("O negative");
expect(text).toContain("ICE contact on the recorded number");
// The baseline report is two pages; the emergency sheet plus its page break
// add exactly one.
expect(buildDoctorReportPdfDocument(data, options).getNumberOfPages()).toBe(
3,
);
});
it("withholds the emergency page when the leaf was not admitted (payload null)", async () => {
const { t } = getServerTranslator("en");
const data = makeData({ emergency: null });
const options = { t, locale: "en" as const, now: FIXED_NOW };
const text = await extractText(renderDoctorReportPdfBytes(data, options));
expect(text).not.toContain("Emergency information");
// No emergency page, no extra page break: back to the two-page baseline.
expect(buildDoctorReportPdfDocument(data, options).getNumberOfPages()).toBe(
2,
);
});
});
describe("extracted doctor-report section boundaries", () => {
it("header/profile prefers the legal name and renders insurance identity", async () => {
const data = makeData({
+10
View File
@@ -25,6 +25,7 @@ import {
import type { Locale } from "./i18n/config";
import { isValidTimezone } from "./tz/format";
import type { DoctorReportData } from "./doctor-report-data";
import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-first-page-section";
import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section";
import { buildMeasurementsChartsSection } from "./doctor-report-pdf/measurements-charts-section";
import { buildMedicationMoodWellnessSection } from "./doctor-report-pdf/medication-mood-wellness-section";
@@ -289,6 +290,15 @@ export function buildDoctorReportPdfDocument(
};
let cursor = pdfCursorState(doc, margin);
// The emergency sheet owns page one. It renders only when emergency data is
// present (the aggregator gates on the EMERGENCY leaf + presence), and when it
// does, an explicit page break sends the rest of the report to page two so the
// opening page carries nothing else.
if (data.emergency) {
cursor = buildEmergencyFirstPageSection(context, cursor);
doc.addPage();
cursor = pdfCursorState(doc, margin);
}
cursor = buildHeaderProfileSection(context, cursor);
cursor = buildMeasurementsChartsSection(context, cursor);
cursor = buildMedicationMoodWellnessSection(context, cursor);
@@ -0,0 +1,158 @@
import {
pdfCursorState,
type DoctorReportPdfCursorState,
type DoctorReportPdfRenderContext,
} from "./render-context";
/**
* Page one of the doctor report: the emergency ("Notfalldaten") sheet a
* clinician reads first in an acute situation.
*
* Rendered before the header profile and followed by an explicit page break, so
* whatever a first responder needs — blood type, severe allergies, the current
* drug list, chronic conditions, implants, the advance-directive and
* organ-donor declarations, who to call — sits alone on the opening page.
*
* The section only renders when `data.emergency` is present; the aggregator
* returns null unless the EMERGENCY leaf was admitted AND the profile holds
* something worth surfacing, so the presence check here is the render half of
* that one gate.
*/
export function buildEmergencyFirstPageSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
): DoctorReportPdfCursorState {
const { doc, data, t, margin, pageWidth, contentMaxY } = context;
const emergency = data.emergency;
if (!emergency) return state;
const contentWidth = pageWidth - 2 * margin;
let y = state.y;
const ensureRoom = (needed: number) => {
if (y + needed > contentMaxY) {
doc.addPage();
y = margin;
}
};
// Banner: a filled bar so the page reads as the emergency sheet at a glance.
doc.setFillColor(180, 30, 30);
doc.rect(margin, y, contentWidth, 12, "F");
doc.setTextColor(255, 255, 255);
doc.setFontSize(15);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.emergency.title"), margin + 3, y + 8);
y += 12;
doc.setTextColor(120, 120, 120);
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
y += 5;
doc.text(t("doctorReport.emergency.subtitle"), margin, y);
y += 6;
// A labelled paragraph block: bold label, wrapped body beneath. `emphasise`
// draws the value darker (blood type, severe allergies) so it carries.
const block = (label: string, body: string, emphasise = false) => {
ensureRoom(5 + 4.8);
doc.setFontSize(10);
doc.setFont("helvetica", "bold");
doc.setTextColor(40, 40, 40);
doc.text(label, margin, y);
y += 5;
doc.setFontSize(emphasise ? 11 : 9.5);
doc.setFont("helvetica", emphasise ? "bold" : "normal");
doc.setTextColor(
emphasise ? 150 : 60,
emphasise ? 20 : 60,
emphasise ? 20 : 60,
);
for (const wrapped of doc.splitTextToSize(body, contentWidth) as string[]) {
ensureRoom(4.8);
doc.text(wrapped, margin, y);
y += 4.8;
}
y += 3;
};
const notRecorded = t("doctorReport.emergency.none");
// Blood type.
const bloodType =
emergency.bloodType && emergency.bloodType !== "UNKNOWN"
? t(`doctorReport.emergency.bloodTypeValues.${emergency.bloodType}`)
: emergency.bloodType === "UNKNOWN"
? t("doctorReport.emergency.bloodTypeUnknown")
: notRecorded;
block(t("doctorReport.emergency.bloodType"), bloodType, true);
// Severe allergies — reuse the report's allergy collection, filter SEVERE.
const severe = (data.allergies ?? []).filter((a) => a.severity === "SEVERE");
if (severe.length > 0) {
const lines = severe.map((a) => {
const reaction = a.reactionUnreadable
? t("doctorReport.emergency.unreadable")
: a.reaction;
return reaction ? `${a.substance} (${reaction})` : a.substance;
});
block(t("doctorReport.emergency.severeAllergies"), lines.join("; "), true);
} else {
block(t("doctorReport.emergency.severeAllergies"), notRecorded);
}
// Active medications — reuse the report's medication list.
const meds = data.medications ?? [];
const medLine =
meds.length > 0
? meds.map((m) => (m.dose ? `${m.name} ${m.dose}` : m.name)).join("; ")
: notRecorded;
block(t("doctorReport.emergency.activeMedications"), medLine);
// Chronic conditions — ongoing illness episodes plus the anamnesis free text.
const chronic = (data.illnessEpisodes ?? [])
.filter((e) => e.lifecycle === "CHRONIC_ONGOING")
.map((e) => e.label);
const anamnesisConditions = data.anamnesis?.conditions ?? null;
const conditionParts = [...chronic];
if (anamnesisConditions) conditionParts.push(anamnesisConditions);
block(
t("doctorReport.emergency.chronicConditions"),
conditionParts.length > 0 ? conditionParts.join("; ") : notRecorded,
);
// Implants / devices.
const implants = emergency.implantsUnreadable
? t("doctorReport.emergency.unreadable")
: (emergency.implants ?? notRecorded);
block(t("doctorReport.emergency.implants"), implants);
// Advance directive + organ donor.
const advanceDirective = emergency.advanceDirective
? t(
`doctorReport.emergency.advanceDirectiveValues.${emergency.advanceDirective}`,
)
: notRecorded;
block(t("doctorReport.emergency.advanceDirective"), advanceDirective);
const organDonor = emergency.organDonor
? t(`doctorReport.emergency.organDonorValues.${emergency.organDonor}`)
: notRecorded;
block(t("doctorReport.emergency.organDonor"), organDonor);
// Emergency contacts.
const contacts = emergency.contactsUnreadable
? t("doctorReport.emergency.unreadable")
: (emergency.contacts ?? notRecorded);
block(t("doctorReport.emergency.contacts"), contacts, true);
// ICE note.
const note = emergency.noteUnreadable
? t("doctorReport.emergency.unreadable")
: emergency.note;
if (note) {
block(t("doctorReport.emergency.notes"), note);
}
return pdfCursorState(doc, y);
}
+24
View File
@@ -25,6 +25,11 @@ import type {
ShiftScheduleValue,
SmokingStatusValue,
} from "@/lib/validations/health-profile-facts";
import type {
AdvanceDirectiveStatusValue,
EmergencyBloodTypeValue,
OrganDonorStatusValue,
} from "@/lib/validations/emergency-profile";
export interface DoctorReportStats {
avg: number;
@@ -395,6 +400,25 @@ export interface DoctorReportData {
"SMOKING_STATUS" | "ALCOHOL_PATTERN" | "SHIFT_SCHEDULE"
>;
} | null;
/**
* Emergency ("Notfalldaten") facts for the page-one section. Present only
* when the `EMERGENCY` leaf was admitted AND at least one field holds a
* value; null otherwise, and the PDF then emits no emergency page. The three
* enums cross as constants (the renderer names them in the report's language);
* the free text decrypts fail-soft, an `*Unreadable` flag distinguishing a
* key-rotation gap from a genuinely unset field.
*/
emergency?: {
bloodType: EmergencyBloodTypeValue | null;
organDonor: OrganDonorStatusValue | null;
advanceDirective: AdvanceDirectiveStatusValue | null;
contacts: string | null;
contactsUnreadable: boolean;
implants: string | null;
implantsUnreadable: boolean;
note: string | null;
noteUnreadable: boolean;
} | null;
}
/** The three persisted score types surfaced in the wellness summary. */
@@ -253,7 +253,19 @@ function seed() {
rows.familyHistoryEntry = [
{ relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
];
rows.userHealthProfileUnique = [{ conditionsEncrypted: new Uint8Array([1]) }];
rows.userHealthProfileUnique = [
{
conditionsEncrypted: new Uint8Array([1]),
// Emergency profile: a real blood type makes the EMERGENCY leaf change
// the payload, so the sweep can prove its control is not inert.
emergencyBloodType: "O_POS",
organDonorStatus: "YES",
advanceDirectiveStatus: "EXISTS",
emergencyContactsEncrypted: null,
emergencyImplantsEncrypted: null,
emergencyNoteEncrypted: null,
},
];
rows.healthProfileFactRevision = [
{
kind: "SMOKING_STATUS",
@@ -290,6 +302,7 @@ describe("per-leaf gating sweep", () => {
expect(allPayload.mood).not.toBeNull();
expect(allPayload.cycle).not.toBeNull();
expect(allPayload.anamnesis).not.toBeNull();
expect(allPayload.emergency).not.toBeNull();
expect(allPayload.glp1).not.toBeNull();
expect(allPayload.medications.length).toBeGreaterThan(0);
expect(allPayload.medicationAdministrations?.length ?? 0).toBeGreaterThan(
@@ -443,17 +456,27 @@ describe("zero read for unchosen leaves", () => {
["VISITS", "encounter"],
["IMMUNIZATIONS", "vaccinationRecord"],
["ANAMNESIS", "userHealthProfile"],
["EMERGENCY", "userHealthProfile"],
["ANAMNESIS", "healthProfileFactRevision"],
];
// ANAMNESIS and EMERGENCY both read `userHealthProfile`, so withholding one
// while the other stays selected still queries the table. Both must be off
// before the read stops — the zero-read guarantee is over the SET of leaves
// that touch a table, not each one in isolation.
const TABLE_READERS: Record<string, ReportLeafId[]> = {
userHealthProfile: ["ANAMNESIS", "EMERGENCY"],
};
it.each(gated)(
"never queries %s's table when it was not chosen",
async (leaf, model) => {
calls.length = 0;
const withheld = new Set<ReportLeafId>(TABLE_READERS[model] ?? [leaf]);
await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(ALL_LEAF_IDS.filter((l) => l !== leaf)),
selectionFromLeaves(ALL_LEAF_IDS.filter((l) => !withheld.has(l))),
{ moduleMap: moduleMap() },
);
expect(calls).not.toContain(model);
@@ -503,6 +526,7 @@ describe("the empty selection serves nothing", () => {
expect(payload.illnessEpisodes).toBeNull();
expect(payload.visits).toBeNull();
expect(payload.anamnesis).toBeNull();
expect(payload.emergency).toBeNull();
expect(payload.cycle).toBeNull();
expect(payload.mood).toBeNull();
expect(payload.glp1).toBeNull();
+21
View File
@@ -18,6 +18,10 @@ import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
import type { DoctorReportData } from "@/lib/doctor-report-types";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
import {
emergencyProfileHasContent,
readEmergencyProfile,
} from "@/lib/profile/emergency-profile";
import type {
HealthProfileFactKind,
HealthProfileFactValue,
@@ -309,6 +313,23 @@ export async function loadFamilyHistory(
return rows.length > 0 ? rows : null;
}
/**
* Emergency ("Notfalldaten") facts for the page-one section. The caller owns
* the selection gate, so reaching this function always means EMERGENCY was
* admitted. Returns null when nothing is worth surfacing, so the PDF emits no
* page over an emergency profile the person never filled in — the presence half
* of the page gate, kept beside the read so the two cannot drift.
*
* The DTO shape is exactly the report's emergency shape, so it crosses through
* unchanged. Free text decrypts fail-soft inside `readEmergencyProfile`.
*/
export async function loadEmergency(
userId: string,
): Promise<DoctorReportData["emergency"]> {
const dto = await readEmergencyProfile(userId);
return emergencyProfileHasContent(dto) ? dto : null;
}
/**
* Chronic conditions and the three current structured facts. The caller owns
* the selection gate, so reaching this function always means ANAMNESIS was
+4
View File
@@ -62,6 +62,7 @@ import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import {
loadAllergies,
loadAnamnesis,
loadEmergency,
loadFamilyHistory,
loadIllnessEpisodes,
loadImmunizations,
@@ -505,6 +506,7 @@ export async function collectDoctorReportData(
allergies,
familyHistory,
anamnesis,
emergency,
] = await Promise.all([
gate.admits("CYCLE")
? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
@@ -528,6 +530,7 @@ export async function collectDoctorReportData(
? loadFamilyHistory(userId)
: Promise.resolve(null),
gate.admits("ANAMNESIS") ? loadAnamnesis(userId) : Promise.resolve(null),
gate.admits("EMERGENCY") ? loadEmergency(userId) : Promise.resolve(null),
]);
const identityOn = gate.admits("PATIENT_IDENTITY");
@@ -596,5 +599,6 @@ export async function collectDoctorReportData(
allergies,
familyHistory,
anamnesis,
emergency,
};
}
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
expect(STRUCTURED_LEAF_IDS).toHaveLength(16);
expect(ALL_LEAF_IDS).toHaveLength(93);
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
expect(ALL_LEAF_IDS).toHaveLength(94);
});
it("resolves a label for every leaf", () => {
@@ -48,11 +48,14 @@ describe("scope picker state", () => {
// control covers something they cannot see or reach.
const identity = REPORT_GROUPS.find((g) => g.id === "identity")!;
const rendered = identity.leaves.filter((l) => l !== "INSURANCE");
const selected = new Set<ReportLeafId>(["PATIENT_IDENTITY"]);
expect(groupCount(rendered, selected)).toEqual({ on: 1, total: 1 });
// The share-link form renders identity minus insurance: patient identity
// and the emergency leaf. Selecting both is "all" of the rendered set even
// though the catalogue group also carries the hidden insurance leaf.
const selected = new Set<ReportLeafId>(["PATIENT_IDENTITY", "EMERGENCY"]);
expect(groupCount(rendered, selected)).toEqual({ on: 2, total: 2 });
expect(groupCheckState(rendered, selected)).toBe("all");
// And the whole group, for contrast.
expect(groupCount(identity.leaves, selected)).toEqual({ on: 1, total: 2 });
// And the whole group, for contrast: insurance is unselected and hidden.
expect(groupCount(identity.leaves, selected)).toEqual({ on: 2, total: 3 });
});
it("never adds a leaf the surface does not render", () => {
+4 -1
View File
@@ -37,6 +37,7 @@ import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"
*/
export type StructuredLeafId =
| "PATIENT_IDENTITY"
| "EMERGENCY"
| "INSURANCE"
| "GLUCOSE_PANEL"
| "LAB_RESULTS"
@@ -192,6 +193,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
/** Structured leaf → group. Exhaustive over the closed union. */
export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
PATIENT_IDENTITY: "identity",
EMERGENCY: "identity",
INSURANCE: "identity",
GLUCOSE_PANEL: "glucose",
LAB_RESULTS: "labs",
@@ -209,9 +211,10 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
ANAMNESIS: "sensitive",
};
/** i18n label keys for the 16 structured leaves. */
/** i18n label keys for the 17 structured leaves. */
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
EMERGENCY: "reportSelection.leafEmergency",
INSURANCE: "reportSelection.leafInsurance",
GLUCOSE_PANEL: "reportSelection.leafGlucosePanel",
LAB_RESULTS: "reportSelection.leafLabResults",
+1
View File
@@ -25,6 +25,7 @@ import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue";
*/
export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"PATIENT_IDENTITY",
"EMERGENCY",
"INSURANCE",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",