Injection Timeline to MAR
How the injection/anesthesia timeline components plug into the MAR.
How the new InjectionTimelineLive / AnesthesiaTimelineLive components plug
into the existing MAR system, with the canonical write path going through
MongoDB and the realtime read path coming through Supabase. Machine
indication (anesthesia machine / infusion pump / external HL7v2 ORM/ORU
adapters) feeds the same canonical write path — so the timeline reflects
whatever the source of truth says, regardless of whether it was a nurse tap
or a machine event.
What’s already in place
Frontend — the newest wired MAR
- MarSystemEnhanced.tsx — the production MAR. The other variants (
MarSystem,MarSystemOld,eMar) are older or shells. - useSupabaseEmarSync.ts — subscribes to two Supabase realtime channels filtered by encounter:
encounter_journey_cache— patient manifest changesmedication_administrations— admin events (encounter_ref=eq.${encounterId})
- MarSystemProvider.tsx — REST backbone:
createMedicationAdministrationApilistAllMedicationAdministrationApiupdateMedicationAdministrationApilistAllMedicationAdministrationEventStatusApigetMedicationRequestListByEncounter(fromever-medication)
Supabase read model — already supports infusion shape
20260507_medication_administrations.sql creates a table that natively supports continuous infusions. Relevant columns:
| Column | Type | Role for the timeline |
|---|---|---|
id |
UUID | InjectionRun.id |
patient_id, encounter_id, medication_request_id, med_order_id |
UUID | Linkage / filter |
administered_at |
TIMESTAMPTZ | InjectionRun.startedAt |
status |
enum (administered, held, refused, omitted, self_administered, not_given) |
Used to decide if the run is closed |
is_infusion |
bool | Filter — only true rows become timeline rows |
pump_id |
text | Joins to the machine indication source |
infusion_rate, rate_unit |
number / text | InjectionRun.rateLabel (e.g. 125 mL/hr) |
bag_volume_ml, bag_remaining_ml |
number | Bag tracking (extension point) |
route |
varchar(20) | PO / IV / IM / SC / SL / INH / TOP / PR — used to split injection vs discrete |
The medication_administrations table needs to gain stopped_at or equivalent (today the timeline derives “stopped” from status != administered + a future end timestamp). See Gap below.
MongoDB — canonical write source
- MedicationAdministration.ts — Mongo entity. Collection:
medication_administration. - Range fields:
start: Date,end: Date— supports continuous infusions out of the box. - Nested
infusion?: { rate, hour, bagNumber }— captures rate / hourly volume / bag number. - Five-Rights array, witness refs,
medicationProviderRef(administered_by). - POST endpoint:
medicationAdministration.create→/medicationAdministrations(Moleculer action inservices/medication).
The gap
There is no Mongo → Supabase projection for medication administration events today.
When medicationAdministration.create writes to Mongo, no edge function listens
and no NATS-driven trigger upserts into Supabase medication_administrations.
The frontend currently fills that table through manual refetches and via
encounter_journey_cache side-effects. As a result:
- If a nurse / machine writes to Mongo, the InjectionTimelineLive bar will not start growing automatically.
- The same flow is invisible to anyone else watching the encounter unless they refetch.
This is the single integration piece that needs to land before the timeline becomes truly machine-sync ready end-to-end.
Target architecture
┌───────────────────────────────────────────────────────────────┐
│ Source of administration event: │
│ • Nurse tap on MAR (MarSystemEnhanced) │
│ • Anesthesia machine / infusion pump (HL7v2 ORU / ORM) │
│ • External adapter (RIS, pharmacy robot) │
└────────────────────────────┬──────────────────────────────────┘
│ POST /medicationAdministrations
▼
┌──────────────────────────────────────────────┐
│ MongoDB (canonical, write truth) │
│ collection: medication_administration │
│ entity has start + end + infusion{…} │
└────────────────────────────┬──────────────────┘
│ NATS event:
│ medication.administration.{created,updated,completed}
▼
┌──────────────────────────────────────────────┐
│ Edge function: mar-mongo-sync ← NEW │
│ (Deno, infrastructure/medbase/functions/) │
│ upsert into Supabase medication_admins │
└────────────────────────────┬──────────────────┘
│ postgres_changes
▼
┌──────────────────────────────────────────────┐
│ Supabase (read model cache) │
│ table: medication_administrations │
└────────────────────────────┬──────────────────┘
│ supabase.channel(...).on(...)
▼
┌──────────────────────────────────────────────┐
│ MarSystemEnhanced + InjectionTimelineLive │
│ row per medication where is_infusion=true │
└──────────────────────────────────────────────┘
Key constraint (from web/CLAUDE.md): the frontend never writes directly
to Supabase read-model tables. All mutations flow through the Mongo write
path; Supabase is one-way out.
Concrete plan
Step 1 — Mount InjectionTimelineLive inside MarSystemEnhanced
For every medication where the route is IV / SC / IM (or is_infusion=true),
render one InjectionTimelineLive strip aligned to the medication row.
Reuse the existing useSupabaseEmarSync subscription — do not open a
second realtime channel for the same table.
Step 2 — Adapter hook: Supabase rows → InjectionRun[]
New hook useInfusionRunsForMar(encounterId, medRequestId):
function useInfusionRunsForMar(encounterId: string, medRequestId: string): InjectionRun[] {
const { administrations } = useSupabaseEmarSync(encounterId);
return useMemo(
() =>
administrations
.filter((a) => a.medication_request_id === medRequestId && a.is_infusion)
.map<InjectionRun>((a) => ({
id: a.id,
startedAt: a.administered_at,
stoppedAt: a.status === 'administered' ? a.end_time ?? undefined : a.administered_at,
rateLabel: a.infusion_rate ? `${a.infusion_rate} ${a.rate_unit ?? 'mL/hr'}` : undefined,
drugLabel: a.medication_name,
})),
[administrations, medRequestId],
);
}
The hook reuses the data useSupabaseEmarSync already populates, so we don’t
double-subscribe. InjectionTimelineLive is then driven by passing
dataSource={{ kind: 'static', runs }} (the static variant added in
InjectionTimelineLive.tsx).
Step 3 — “Done” path: canonical write to Mongo
When (later) a manual stop control is re-added, or when the machine fires a stop event, the path is the same:
await updateMedicationAdministrationApi(adminId, {
status: 'completed',
end: new Date().toISOString(),
});
Mongo is updated → NATS event → edge function (Step 4) → Supabase row gets
status='completed' → realtime subscription pushes the change → the
timeline bar caps and the live pulse stops.
The frontend never touches Supabase for this write.
Step 4 — Close the gap: mar-mongo-sync edge function
A new Deno edge function at
infrastructure/medbase/functions/mar-mongo-sync/index.ts. Listens to the
hospital_events topic for medication.administration.* events and upserts
into medication_administrations keyed by id. ~80 lines, mirrors the
encounter orchestrator pattern documented at
docs/architecture/encounter-orchestrator-triggers.md.
Required Supabase schema additions
The existing medication_administrations table needs one column for the
range close:
ALTER TABLE medication_administrations ADD COLUMN end_time TIMESTAMPTZ;
COMMENT ON COLUMN medication_administrations.end_time IS
'Range end for continuous infusions. Null while infusion is running.';
Optional but useful for the InjectionTimelineLive drugLabel:
ALTER TABLE medication_administrations ADD COLUMN medication_name TEXT;
(or join to medication_request on read — adapter hook can resolve either way.)
Machine indication ingestion
Two sources to wire over time, both reduce to “produce an
AdministrationEvent and POST to /medicationAdministrations”:
| Source | Adapter | Status |
|---|---|---|
| Anesthesia machine (continuous gas / drip) | HL7v2 ORU R01 via services/interoperability/.../modules/hl7v2/oru-to-medos.mapper.ts (already exists for lab — extend for med admin) |
Open |
| Infusion pump | MLLP listener on port 2575 (already exists), new mapper for pump.run.{started,completed} events |
Open |
| External vendor (e.g. BD Alaris, Hospira) | Vendor-specific adapter writing into the same shape | Open |
All three converge on the same Mongo write → so the timeline doesn’t have to know who initiated the event.
Frontend surfaces today (verified)
- DynamicContentRenderer cases registered:
modules.InjectionTimelineLive→ single-drug panelmodules.AnesthesiaTimelineLive→ 6-drug anesthesia stack
- Sandbox targets:
http://localhost:5189/?target=InjectionTimelineLivehttp://localhost:5189/?target=AnesthesiaTimelineLive
- Component file map:
- InjectionTimeline.tsx — stateless, optional
hideControls+syncBadgeprops - InjectionTimelineLive.tsx —
useInfusionRuns(dataSource)supportsmock/static/supabase/websocket - AnesthesiaTimelineLive.tsx — stack of
InjectionTimelineLive, 2h/6h/12h/24h window toggle
- InjectionTimeline.tsx — stateless, optional
Open questions
- Do we want
end_timeas a new column, or model the range as two rows (start event + completion event) keyed by arun_id? Two-row model is more FHIR-faithful but more code. Single-row withend_timeis simpler and matches the Mongo entity shape (start,end). - Should
mar-mongo-syncalso project toencounter_journey_cache.medicationsto keep the patient manifest fresh, or leave that to the existing encounter orchestrator? - For the in-renderer wiring inside MarSystemEnhanced — is the right place inline under each medication row, or a side-panel drawer that opens for the currently selected medication? The first is more glanceable, the second saves vertical space.
References
- web/CLAUDE.md — read model rule, dual data source rule
- docs/architecture/encounter-orchestrator-triggers.md — pattern for the new edge function
- docs/architecture/acknowledgement-system.md — universal acknowledgement (could trigger witness co-sign on infusion stop)