mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(fhir): add a MedicationAdministration per acted intake
Emit one `MedicationAdministration` per intake the user actually actioned — `completed` for a taken dose (`effectiveDateTime = takenAt`), `not-done` for an explicit skip (`effectiveDateTime = scheduledFor`). Pending / missed slots and soft-deleted tombstones are never exported, so the record only asserts administrations that happened. Each administration reuses the statement's ATC/RxNorm + text concept, so it is self-describing without a cross-resource reference. A `dosage` is emitted only when a structured dose Quantity is available (resolved from the dose-change history in effect at the administration instant) — a dosage carrying only `.text` would violate the R4 dose-or-rate invariant. Route + injection site ride along as text-only concepts. The administrations are referenced from the existing Medications section, so the document's two-section graph is preserved. `FhirMedicationAdministration` is new; `FhirDosage` gains `dose`, `route`, and `site`. The source rows come from the same aggregator the PDF consumes, keeping FHIR and PDF aligned by construction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d)
This commit is contained in:
1 parent
2fcd2e75f5
commit
ca8781867b
4 files changed
+424
-3
No files matched your search
@@ -4,11 +4,23 @@ import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type {
|
||||
FhirObservation,
|
||||
FhirMedicationStatement,
|
||||
FhirMedicationAdministration,
|
||||
FhirPatient,
|
||||
FhirCoverage,
|
||||
FhirDiagnosticReport,
|
||||
} from "../types";
|
||||
|
||||
function administrationsOf(
|
||||
bundle: ReturnType<typeof buildFhirDocumentBundle>,
|
||||
): FhirMedicationAdministration[] {
|
||||
return bundle.entry
|
||||
.map((e) => e.resource)
|
||||
.filter(
|
||||
(r): r is FhirMedicationAdministration =>
|
||||
r.resourceType === "MedicationAdministration",
|
||||
);
|
||||
}
|
||||
|
||||
function coverageOf(
|
||||
bundle: ReturnType<typeof buildFhirDocumentBundle>,
|
||||
): FhirCoverage | undefined {
|
||||
@@ -413,6 +425,143 @@ describe("buildFhirDocumentBundle", () => {
|
||||
expect(coding?.[0].code).toBe("C09AA05");
|
||||
});
|
||||
|
||||
it("maps a taken intake to a completed MedicationAdministration with a structured dose", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
medicationAdministrations: [
|
||||
{
|
||||
medicationName: "Mounjaro",
|
||||
effectiveAt: "2026-04-30T08:12:00.000Z",
|
||||
status: "completed",
|
||||
doseText: "10mg",
|
||||
dose: { value: 10, unit: "mg" },
|
||||
injectionSite: "abdomen-left",
|
||||
atcCode: "A10BX10",
|
||||
rxNormCode: "2601723",
|
||||
deliveryForm: "INJECTION",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: null },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const admins = administrationsOf(bundle);
|
||||
expect(admins).toHaveLength(1);
|
||||
const a = admins[0];
|
||||
expect(a.status).toBe("completed");
|
||||
expect(a.effectiveDateTime).toBe("2026-04-30T08:12:00.000Z");
|
||||
// Self-describing concept reuses the ATC/RxNorm coding + text anchor.
|
||||
expect(a.medicationCodeableConcept.text).toBe("Mounjaro");
|
||||
expect(a.medicationCodeableConcept.coding?.[0].code).toBe("A10BX10");
|
||||
// Structured dose satisfies the R4 dose-or-rate invariant.
|
||||
expect(a.dosage?.dose).toEqual({
|
||||
value: 10,
|
||||
unit: "mg",
|
||||
system: "http://unitsofmeasure.org",
|
||||
code: "mg",
|
||||
});
|
||||
expect(a.dosage?.text).toBe("10mg");
|
||||
expect(a.dosage?.route?.text).toBe("Injection");
|
||||
expect(a.dosage?.site?.text).toBe("abdomen-left");
|
||||
});
|
||||
|
||||
it("maps a skipped intake to a not-done administration with no dosage", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
medicationAdministrations: [
|
||||
{
|
||||
medicationName: "Ramipril",
|
||||
effectiveAt: "2026-04-29T08:00:00.000Z",
|
||||
status: "not-done",
|
||||
doseText: "5mg",
|
||||
dose: null,
|
||||
injectionSite: null,
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
deliveryForm: "ORAL",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: null },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const admins = administrationsOf(bundle);
|
||||
expect(admins).toHaveLength(1);
|
||||
expect(admins[0].status).toBe("not-done");
|
||||
expect(admins[0].effectiveDateTime).toBe("2026-04-29T08:00:00.000Z");
|
||||
// No structured dose → no dosage block (R4 dose-or-rate invariant).
|
||||
expect(admins[0].dosage).toBeUndefined();
|
||||
// Text-only concept when the medication has no codes.
|
||||
expect(admins[0].medicationCodeableConcept.coding).toBeUndefined();
|
||||
expect(admins[0].medicationCodeableConcept.text).toBe("Ramipril");
|
||||
});
|
||||
|
||||
it("omits the dosage when a taken dose has no structured dose Quantity", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
medicationAdministrations: [
|
||||
{
|
||||
medicationName: "Vitamin D3",
|
||||
effectiveAt: "2026-04-30T20:00:00.000Z",
|
||||
status: "completed",
|
||||
doseText: "1000 IU",
|
||||
dose: null,
|
||||
injectionSite: null,
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
deliveryForm: "ORAL",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: null },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const a = administrationsOf(bundle)[0];
|
||||
expect(a.status).toBe("completed");
|
||||
// A dosage with only `.text` would violate the dose-or-rate
|
||||
// invariant; the builder omits the dosage entirely instead.
|
||||
expect(a.dosage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits no MedicationAdministration when there are no acted intakes", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ medicationAdministrations: [] }),
|
||||
{ insuranceNumber: null },
|
||||
FIXED_NOW,
|
||||
);
|
||||
expect(administrationsOf(bundle)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("references the administrations from the Medications composition section", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
medicationAdministrations: [
|
||||
{
|
||||
medicationName: "Mounjaro",
|
||||
effectiveAt: "2026-04-30T08:12:00.000Z",
|
||||
status: "completed",
|
||||
doseText: "10mg",
|
||||
dose: { value: 10, unit: "mg" },
|
||||
injectionSite: null,
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
deliveryForm: "INJECTION",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: null },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const composition = bundle.entry[0].resource;
|
||||
expect(composition.resourceType).toBe("Composition");
|
||||
const medSection =
|
||||
composition.resourceType === "Composition"
|
||||
? composition.section?.find((s) => s.title === "Medications")
|
||||
: undefined;
|
||||
const refs = medSection?.entry?.map((e) => e.reference) ?? [];
|
||||
expect(refs).toContain("MedicationAdministration/medadmin-1");
|
||||
});
|
||||
|
||||
it("omits the mood Observation when mood is null (privacy default)", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ mood: null }),
|
||||
|
||||
@@ -39,6 +39,8 @@ import type {
|
||||
FhirCodeableConcept,
|
||||
FhirObservation,
|
||||
FhirMedicationStatement,
|
||||
FhirMedicationAdministration,
|
||||
FhirDosage,
|
||||
FhirPatient,
|
||||
FhirCoverage,
|
||||
FhirOrganization,
|
||||
@@ -90,6 +92,54 @@ function medicationConcept(
|
||||
return coding.length > 0 ? { coding, text: name } : { text: name };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.9.0 — UCUM codes for the dose units HealthLog stores. The display
|
||||
* `unit` is always the user's original string; the UCUM `code` is set
|
||||
* only for an unambiguous mapping so a consumer that resolves UCUM never
|
||||
* sees a guessed code. An unmapped unit drops `code` (and `system`) and
|
||||
* keeps just the human-readable `unit` — conformant, just not coded.
|
||||
*/
|
||||
const UCUM_DOSE_CODES: Record<string, string> = {
|
||||
mg: "mg",
|
||||
g: "g",
|
||||
mcg: "ug",
|
||||
µg: "ug",
|
||||
ug: "ug",
|
||||
ml: "mL",
|
||||
mL: "mL",
|
||||
};
|
||||
|
||||
function doseQuantity(value: number, unit: string): {
|
||||
value: number;
|
||||
unit: string;
|
||||
system?: string;
|
||||
code?: string;
|
||||
} {
|
||||
const ucum = UCUM_DOSE_CODES[unit];
|
||||
return ucum
|
||||
? { value, unit, system: UCUM_SYSTEM, code: ucum }
|
||||
: { value, unit };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.9.0 — text-only route of administration derived from the
|
||||
* medication's delivery form. Text-only (no SNOMED licence concern); the
|
||||
* design doc upgrades to coded routes jointly with iOS later. Returns
|
||||
* `undefined` for an unknown / absent form so no empty route is emitted.
|
||||
*/
|
||||
function routeConcept(
|
||||
deliveryForm: string | null,
|
||||
): FhirCodeableConcept | undefined {
|
||||
switch (deliveryForm) {
|
||||
case "ORAL":
|
||||
return { text: "Oral" };
|
||||
case "INJECTION":
|
||||
return { text: "Injection" };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape the five XML-significant characters for the xhtml narrative. */
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
@@ -434,11 +484,57 @@ export function buildFhirDocumentBundle(
|
||||
medicationRefs.push({ reference: `MedicationStatement/${id}` });
|
||||
}
|
||||
|
||||
// --- MedicationAdministration per acted intake -------------------------
|
||||
// One resource per intake the user actually actioned: `completed`
|
||||
// (taken) or `not-done` (explicitly skipped). Pending / missed slots and
|
||||
// soft-deleted tombstones are excluded upstream by the aggregator. The
|
||||
// concept reuses the same ATC/RxNorm coding as the statement so each
|
||||
// administration is self-describing without resolving a reference (no
|
||||
// `partOf` / `request` coupling — see the v1.9.0 design doc). A `dosage`
|
||||
// is emitted ONLY when a structured `dose` Quantity is available; a
|
||||
// dosage with only `.text` would violate the R4 dose-or-rate invariant.
|
||||
const administrationRefs: FhirReference[] = [];
|
||||
let adminSeq = 0;
|
||||
for (const admin of data.medicationAdministrations ?? []) {
|
||||
adminSeq += 1;
|
||||
const id = `medadmin-${adminSeq}`;
|
||||
const resource: FhirMedicationAdministration = {
|
||||
resourceType: "MedicationAdministration",
|
||||
id,
|
||||
status: admin.status,
|
||||
medicationCodeableConcept: medicationConcept(
|
||||
admin.medicationName,
|
||||
admin.atcCode,
|
||||
admin.rxNormCode,
|
||||
),
|
||||
subject: patientRef,
|
||||
effectiveDateTime: admin.effectiveAt,
|
||||
};
|
||||
|
||||
// Dosage: only when a structured dose Quantity exists. Carry the
|
||||
// free-text dose + route + site alongside it. Text-only route/site
|
||||
// (no SNOMED licence concern); the design doc upgrades to coded
|
||||
// routes jointly with iOS later.
|
||||
if (admin.dose) {
|
||||
const dosage: FhirDosage = {
|
||||
dose: doseQuantity(admin.dose.value, admin.dose.unit),
|
||||
};
|
||||
if (admin.doseText) dosage.text = admin.doseText;
|
||||
const route = routeConcept(admin.deliveryForm);
|
||||
if (route) dosage.route = route;
|
||||
if (admin.injectionSite) dosage.site = { text: admin.injectionSite };
|
||||
resource.dosage = dosage;
|
||||
}
|
||||
|
||||
entries.push({ fullUrl: `urn:uuid:${id}`, resource });
|
||||
administrationRefs.push({ reference: `MedicationAdministration/${id}` });
|
||||
}
|
||||
|
||||
// --- Composition (leading "cover" resource) ----------------------------
|
||||
const narrativeText = [
|
||||
`Health record for ${escapeXml(displayName ?? "patient")}.`,
|
||||
`Reporting period ${data.period.start.slice(0, 10)} to ${data.period.end.slice(0, 10)}.`,
|
||||
`${observationRefs.length} observation(s), ${medicationRefs.length} medication(s).`,
|
||||
`${observationRefs.length} observation(s), ${medicationRefs.length} medication(s), ${administrationRefs.length} administration(s).`,
|
||||
].join(" ");
|
||||
|
||||
const composition: FhirComposition = {
|
||||
@@ -467,8 +563,17 @@ export function buildFhirDocumentBundle(
|
||||
text: { status: "generated", div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>` },
|
||||
entry: [patientRef, ...observationRefs],
|
||||
},
|
||||
...(medicationRefs.length > 0
|
||||
? [{ title: "Medications", entry: medicationRefs }]
|
||||
// Medications section carries both the active-medication
|
||||
// statements and the per-dose administration records (v1.9.0), so
|
||||
// the iOS two-section graph ("Vital signs" + "Medications") is
|
||||
// preserved without adding a third section.
|
||||
...(medicationRefs.length > 0 || administrationRefs.length > 0
|
||||
? [
|
||||
{
|
||||
title: "Medications",
|
||||
entry: [...medicationRefs, ...administrationRefs],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -79,6 +79,17 @@ export interface FhirObservation {
|
||||
|
||||
export interface FhirDosage {
|
||||
text?: string;
|
||||
/**
|
||||
* v1.9.0 — structured dose `Quantity`. On a `MedicationAdministration`
|
||||
* a `dosage` SHALL carry at least one of `dose` or `rate` (R4
|
||||
* invariant), so the builder only emits a `dosage` block when a `dose`
|
||||
* is available; a text-only dose stays on the `MedicationStatement`.
|
||||
*/
|
||||
dose?: FhirQuantity;
|
||||
/** v1.9.0 — route of administration. Text-only by default (no SNOMED licence concern). */
|
||||
route?: FhirCodeableConcept;
|
||||
/** v1.9.0 — administration body-site (e.g. injection site). Text-only by default. */
|
||||
site?: FhirCodeableConcept;
|
||||
}
|
||||
|
||||
export interface FhirPeriod {
|
||||
@@ -96,6 +107,27 @@ export interface FhirMedicationStatement {
|
||||
dosage?: FhirDosage[];
|
||||
}
|
||||
|
||||
export interface FhirMedicationAdministration {
|
||||
resourceType: "MedicationAdministration";
|
||||
id: string;
|
||||
/**
|
||||
* v1.9.0 — only `completed` (a taken dose) or `not-done` (an explicit
|
||||
* skip) are emitted; the exporter never asserts an administration for a
|
||||
* pending / missed slot or a soft-deleted tombstone.
|
||||
*/
|
||||
status: "completed" | "not-done";
|
||||
/** Self-describing concept — the same ATC/RxNorm + text the statement carries. */
|
||||
medicationCodeableConcept: FhirCodeableConcept;
|
||||
subject: FhirReference;
|
||||
/**
|
||||
* `takenAt` for a `completed` administration, `scheduledFor` (the
|
||||
* intended instant) for a `not-done` skip.
|
||||
*/
|
||||
effectiveDateTime: string;
|
||||
/** Single dosage (R4 `0..1`). Present only when a structured `dose` exists. */
|
||||
dosage?: FhirDosage;
|
||||
}
|
||||
|
||||
export interface FhirDiagnosticReport {
|
||||
resourceType: "DiagnosticReport";
|
||||
id: string;
|
||||
@@ -152,6 +184,7 @@ export type FhirResource =
|
||||
| FhirCoverage
|
||||
| FhirObservation
|
||||
| FhirMedicationStatement
|
||||
| FhirMedicationAdministration
|
||||
| FhirDiagnosticReport;
|
||||
|
||||
export interface FhirBundleEntry {
|
||||
|
||||
Reference in new issue
Block a user