feat(labs): surface lab results in the doctor report and FHIR export

Add a Lab results section to the report aggregator (latest reading per
analyte over the window), a new labs section toggle (ON by default, the
same stance as BP and weight since the user recorded these to share with
a clinician), a PDF table with a quiet non-colour in/out-of-range marker,
and one laboratory Observation per result in the FHIR bundle carrying the
lab's reported referenceRange. Everything stays server-authoritative.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:39:29 +02:00
1 parent 271cc0dcfa
commit fbecb6b5c8
12 files changed
+394

No files matched your search

@@ -91,6 +91,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
compliance: true,
sleep: false,
cycle: false,
labs: true,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
@@ -114,6 +115,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
compliance: false,
sleep: false,
cycle: false,
labs: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
@@ -9,6 +9,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
},
@@ -71,6 +72,7 @@ function setEmptyDataMocks() {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "testuser",
dateOfBirth: null,
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
@@ -74,6 +75,7 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
// First findUnique = aggregator profile select; second = route KVNR select.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
@@ -87,6 +87,11 @@ interface SectionAvailability {
// the cycle toggle. Present so `keyof DoctorReportPrefs` indexing
// typechecks. Optional — the availability endpoint may not provide it.
cycle?: boolean;
// v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog;
// the flagship export panel surfaces the toggle. Optional — the
// availability endpoint may not provide it. Present so the
// `keyof DoctorReportPrefs` indexing typechecks.
labs?: boolean;
}
export interface DoctorReportSubmitPayload {
@@ -547,6 +552,9 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
// v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it);
// present so the `keyof DoctorReportPrefs` record stays exhaustive.
cycle: "doctorReport.sections.cycle",
// v1.17.1 — labs section; the flagship export panel drives the toggle,
// present here so the `keyof DoctorReportPrefs` record stays exhaustive.
labs: "doctorReport.sections.labs",
};
/**
+70
View File
@@ -230,6 +230,31 @@ export interface DoctorReportData {
* still typecheck.
*/
cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null;
/**
* v1.17.1 — structured lab results recorded over the report window.
* Populated when the `labs` section toggle is ON (default) and the user
* recorded at least one result in the period. Statistics + the single
* latest reading per analyte; free-text notes never reach this surface.
* Each entry carries the lab's reference bounds so the PDF can print an
* in/out-of-range marker and the FHIR exporter can emit `referenceRange`.
* Null/absent otherwise; both the PDF and FHIR builders skip the section.
* Optional so pre-v1.17.1 fixtures still typecheck.
*/
labResults?: Array<{
/** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */
panel: string | null;
/** Biomarker name as the user recorded it. */
analyte: string;
/** Latest recorded value for this analyte over the window. */
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** ISO timestamp of the latest reading. */
takenAt: string;
/** Count of readings for this analyte in the window (≥ 1). */
count: number;
}> | null;
}
/** The three persisted score types surfaced in the wellness summary. */
@@ -1057,6 +1082,50 @@ export async function collectDoctorReportData(
);
}
// v1.17.1 — structured lab results over the window. ON by default; the
// user recorded these specifically to share with a clinician, so the
// privacy stance matches BP / weight, not mood / cycle. We reduce to the
// latest reading per analyte (with a count) so the report is a concise
// panel, not a raw dump; notes are never read here.
let labResults: DoctorReportData["labResults"] = null;
if (sections.labs) {
const labRows = await prisma.labResult.findMany({
where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
orderBy: { takenAt: "asc" },
select: {
panel: true,
analyte: true,
value: true,
unit: true,
referenceLow: true,
referenceHigh: true,
takenAt: true,
},
});
// Latest-per-analyte, keyed case-insensitively so "LDL" / "ldl" fold
// together; rows are ascending so the last seen wins as the latest.
const byAnalyte = new Map<
string,
NonNullable<DoctorReportData["labResults"]>[number]
>();
for (const r of labRows) {
const key = r.analyte.toLowerCase();
const prev = byAnalyte.get(key);
byAnalyte.set(key, {
panel: r.panel,
analyte: r.analyte,
value: r.value,
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
takenAt: r.takenAt.toISOString(),
count: (prev?.count ?? 0) + 1,
});
}
const collapsed = Array.from(byAnalyte.values());
labResults = collapsed.length > 0 ? collapsed : null;
}
return {
period: {
days,
@@ -1109,6 +1178,7 @@ export async function collectDoctorReportData(
glp1,
wellnessScores,
cycle,
labResults,
};
}
+83
View File
@@ -1319,6 +1319,89 @@ export function buildDoctorReportPdfDocument(
}
}
// v1.17.1 — structured lab-results section. Populated when the `labs`
// toggle is ON (default) and the user recorded at least one result in the
// window. One row per analyte (latest reading), with the lab's reference
// range and a NEUTRAL in/out-of-range marker — informative, never an
// alarming red. The marker is a quiet glyph (↓ / ↑ / —), not a colour, so
// the clinical PDF stays calm.
if (data.labResults && data.labResults.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.labsTitle"), margin, y);
y += 6;
const rangeText = (
low: number | null,
high: number | null,
): string => {
if (low !== null && high !== null) return `${num(low)}–${num(high)}`;
if (high !== null) return `≤ ${num(high)}`;
if (low !== null) return `≥ ${num(low)}`;
return "—";
};
// Quiet, non-colour status glyph. In-range reads as a neutral dash so
// the table is not a field of warning marks; out-of-range reads as a
// direction arrow the clinician can scan, with no alarm tint.
const statusGlyph = (
value: number,
low: number | null,
high: number | null,
): string => {
if (low === null && high === null) return "";
if (low !== null && value < low) return "↓";
if (high !== null && value > high) return "↑";
return "—";
};
const labRows = data.labResults.map((lr) => [
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
`${num(lr.value)} ${lr.unit}`.trim(),
rangeText(lr.referenceLow, lr.referenceHigh),
statusGlyph(lr.value, lr.referenceLow, lr.referenceHigh),
fmtDate(lr.takenAt),
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.labsColAnalyte"),
t("doctorReport.labsColValue"),
t("doctorReport.labsColReference"),
t("doctorReport.labsColStatus"),
t("doctorReport.labsColDate"),
],
],
body: labRows,
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
@@ -0,0 +1,159 @@
import { describe, it, expect } from "vitest";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { buildFhirDocumentBundle } from "../build-bundle";
import type { FhirObservation } from "../types";
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
return {
period: {
days: 90,
since: "2026-02-02T00:00:00.000Z",
start: "2026-02-02T00:00:00.000Z",
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
},
practiceName: null,
measurements: {},
stats: {},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
glucoseUnit: "mg/dL",
bmi: null,
compliance: {},
medications: [],
mood: null,
glp1: null,
...overrides,
};
}
function observationsOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
return bundle.entry
.map((e) => e.resource)
.filter((r): r is FhirObservation => r.resourceType === "Observation");
}
describe("lab-result FHIR Observations", () => {
const labResults: NonNullable<DoctorReportData["labResults"]> = [
{
panel: "Lipid panel",
analyte: "LDL",
value: 110,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 116,
takenAt: "2026-04-20T08:00:00.000Z",
count: 2,
},
{
panel: null,
analyte: "HbA1c",
value: 5.4,
unit: "%",
referenceLow: 4,
referenceHigh: 5.6,
takenAt: "2026-04-21T08:00:00.000Z",
count: 1,
},
];
it("emits one laboratory Observation per lab result", () => {
const bundle = buildFhirDocumentBundle(
makeData({ labResults }),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const labObs = observationsOf(bundle).filter((o) =>
o.category?.some((c) =>
c.coding?.some((code) => code.code === "laboratory"),
),
);
// Two lab observations (no glucose in this fixture).
expect(labObs).toHaveLength(2);
});
it("maps the value, unit, and effective date onto the Observation", () => {
const bundle = buildFhirDocumentBundle(
makeData({ labResults }),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)");
expect(ldl).toBeDefined();
expect(ldl?.valueQuantity?.value).toBe(110);
expect(ldl?.valueQuantity?.unit).toBe("mg/dL");
expect(ldl?.effectiveDateTime).toBe("2026-04-20T08:00:00.000Z");
});
it("emits a referenceRange with only the high bound when low is null", () => {
const bundle = buildFhirDocumentBundle(
makeData({ labResults }),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)");
expect(ldl?.referenceRange).toHaveLength(1);
expect(ldl?.referenceRange?.[0].high?.value).toBe(116);
expect(ldl?.referenceRange?.[0].low).toBeUndefined();
});
it("emits a referenceRange with both bounds", () => {
const bundle = buildFhirDocumentBundle(
makeData({ labResults }),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const a1c = observationsOf(bundle).find((o) => o.code.text === "HbA1c");
expect(a1c?.referenceRange?.[0].low?.value).toBe(4);
expect(a1c?.referenceRange?.[0].high?.value).toBe(5.6);
});
it("omits referenceRange entirely when no bounds are present", () => {
const bundle = buildFhirDocumentBundle(
makeData({
labResults: [
{
panel: null,
analyte: "CRP",
value: 2.1,
unit: "mg/L",
referenceLow: null,
referenceHigh: null,
takenAt: "2026-04-22T08:00:00.000Z",
count: 1,
},
],
}),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const crp = observationsOf(bundle).find((o) => o.code.text === "CRP");
expect(crp).toBeDefined();
expect(crp?.referenceRange).toBeUndefined();
});
it("emits no lab Observations when labResults is null", () => {
const bundle = buildFhirDocumentBundle(
makeData({ labResults: null }),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const labObs = observationsOf(bundle).filter((o) =>
o.category?.some((c) =>
c.coding?.some((code) => code.code === "laboratory"),
),
);
expect(labObs).toHaveLength(0);
});
});
+41
View File
@@ -511,6 +511,47 @@ export function observationsFromReportData(
});
}
// --- Lab-result Observations (v1.17.1) ---------------------------------
// One `laboratory` Observation per recorded analyte. `analyte` + `unit`
// are user-recorded free text (no closed LOINC enum), so the code is a
// text-only `CodeableConcept` — honest about what we have rather than
// asserting a LOINC mapping we can't validate. The lab's reference bounds
// map onto R4 `referenceRange`. UCUM `code` is omitted (the free-text
// unit can't be asserted as a UCUM symbol); `unit` carries the display.
for (const lab of data.labResults ?? []) {
obsSeq += 1;
const referenceRange =
lab.referenceLow !== null || lab.referenceHigh !== null
? [
{
...(lab.referenceLow !== null
? { low: { value: lab.referenceLow, unit: lab.unit } }
: {}),
...(lab.referenceHigh !== null
? { high: { value: lab.referenceHigh, unit: lab.unit } }
: {}),
},
]
: undefined;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("laboratory")],
code: {
text: lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte,
},
subject: patientRef,
effectiveDateTime: lab.takenAt,
valueQuantity: {
value: lab.value,
unit: lab.unit,
system: UCUM_SYSTEM,
},
...(referenceRange ? { referenceRange } : {}),
});
}
// --- Medication-adherence Observations (one per medication) ------------
for (const [name, comp] of Object.entries(data.compliance)) {
if (comp.total <= 0) continue;
+12
View File
@@ -85,6 +85,18 @@ export interface FhirObservation {
component?: FhirObservationComponent[];
/** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */
note?: FhirAnnotation[];
/**
* v1.17.1 — R4 `Observation.referenceRange`. Carries the lab's reported
* normal bounds for a structured lab result; either bound may be absent
* (a lab may report only an upper or lower limit).
*/
referenceRange?: FhirObservationReferenceRange[];
}
/** R4 `Observation.referenceRange` element. */
export interface FhirObservationReferenceRange {
low?: FhirQuantity;
high?: FhirQuantity;
}
export interface FhirDosage {
@@ -57,6 +57,11 @@ describe("parseDoctorReportPrefs", () => {
expect(out.mood).toBe(false);
});
it("defaults labs to ON (recorded to share with a clinician)", () => {
const out = parseDoctorReportPrefs(null);
expect(out.labs).toBe(true);
});
it("fills missing keys from defaults", () => {
const out = parseDoctorReportPrefs({ bp: false });
expect(out).toEqual({
@@ -86,6 +91,7 @@ describe("resolveDoctorReportPrefs", () => {
compliance: false,
sleep: false,
cycle: false,
labs: false,
};
const out = resolveDoctorReportPrefs(current, { mood: false });
expect(out).toEqual({ ...current, mood: false });
@@ -32,6 +32,7 @@ export const doctorReportPrefsSchema = z
compliance: z.boolean(),
sleep: z.boolean(),
cycle: z.boolean(),
labs: z.boolean(),
})
.partial();
@@ -51,6 +52,7 @@ export interface DoctorReportPrefs {
compliance: boolean;
sleep: boolean;
cycle: boolean;
labs: boolean;
}
/**
@@ -70,6 +72,9 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
// Cycle data is opt-in: a user sharing a BP report with a cardiologist
// should not auto-leak reproductive data. Same privacy stance as mood.
cycle: false,
// Lab results the user recorded for exactly this purpose — sharing
// bloodwork with a clinician. ON by default, like BP / weight.
labs: true,
};
/**
@@ -74,6 +74,9 @@ export const exportSectionsSchema = z
bmi: z.boolean(),
// Cycle / reproductive health — opt-in (privacy default OFF, like mood).
cycle: z.boolean(),
// Structured lab results — ON by default (recorded to share with a
// clinician, same stance as BP / weight).
labs: z.boolean(),
})
.partial();
@@ -135,5 +138,6 @@ export function toDoctorReportPrefs(
sleep: a.sleep ?? fallback.sleep,
// Cycle is opt-in: only true when explicitly requested (privacy).
cycle: s.cycle === true,
labs: s.labs ?? fallback.labs,
};
}