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:
Marc-André Bombeck committed 2026-06-02 16:45:23 +02:00
1 parent 2fcd2e75f5
commit ca8781867b
4 files changed
+424 -3

No files matched your search

+149
View File
@@ -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 }),
+108 -3
View File
@@ -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],
},
]
: []),
],
};
+33
View File
@@ -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 {