From fbecb6b5c88392569e491663033183f82e1e9d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:39:29 +0200 Subject: [PATCH] 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. --- .../__tests__/route.test.ts | 2 + .../doctor-report/__tests__/pdf-route.test.ts | 2 + .../health-record/__tests__/route.test.ts | 2 + .../doctor-report/doctor-report-dialog.tsx | 8 + src/lib/doctor-report-data.ts | 70 ++++++++ src/lib/doctor-report-pdf-core.ts | 83 +++++++++ .../fhir/__tests__/lab-observations.test.ts | 159 ++++++++++++++++++ src/lib/fhir/resources.ts | 41 +++++ src/lib/fhir/types.ts | 12 ++ .../__tests__/doctor-report-prefs.test.ts | 6 + src/lib/validations/doctor-report-prefs.ts | 5 + src/lib/validations/health-record-export.ts | 4 + 12 files changed, 394 insertions(+) create mode 100644 src/lib/fhir/__tests__/lab-observations.test.ts diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index 4550fc90c..10b591041 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -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)(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, diff --git a/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts index 976b297da..50fb75504 100644 --- a/src/app/api/doctor-report/__tests__/pdf-route.test.ts +++ b/src/app/api/doctor-report/__tests__/pdf-route.test.ts @@ -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, diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 95b700be2..bc8e1a7c0 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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", diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index ad53fc728..4af0bd050 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -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 = { // 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", }; /** diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 4b0aacdef..62ddc5ee0 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -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[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, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 1a14e0543..db9bd0fb9 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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 diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts new file mode 100644 index 000000000..9d6dcb145 --- /dev/null +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -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 { + 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) { + return bundle.entry + .map((e) => e.resource) + .filter((r): r is FhirObservation => r.resourceType === "Observation"); +} + +describe("lab-result FHIR Observations", () => { + const labResults: NonNullable = [ + { + 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); + }); +}); diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index f970e1216..41c6e55dd 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -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; diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 8d2845e55..883606b52 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -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 { diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index 7c6b2f874..f57fa136c 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -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 }); diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index d390c6261..cd104823d 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -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, }; /** diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index d453f785e..5f631c6a6 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -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, }; }