ezmedicationinput 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -352,6 +352,58 @@ Key rules:
352
352
 
353
353
  `from` is required and marks the evaluation window. `orderedAt` is optional—when supplied it acts as the baseline for interval calculations; otherwise the `from` timestamp is reused. The options bag also accepts `timeZone`, `eventClock`, `mealOffsets`, and `frequencyDefaults` at the top level (mirroring the legacy `config` object). `limit` defaults to 10 when omitted.
354
354
 
355
+ ### Medication amount calculation
356
+
357
+ `calculateTotalUnits` computes the total amount of medication (and optionally the number of containers) required for a specific duration. It accounts for complex schedules, dose ranges (using the high value), and unit conversions between doses and containers.
358
+
359
+ ```ts
360
+ import { calculateTotalUnits, parseSig } from "ezmedicationinput";
361
+
362
+ const { fhir } = parseSig("1x3 po pc");
363
+
364
+ const result = calculateTotalUnits({
365
+ dosage: fhir,
366
+ from: "2024-01-01T08:00:00Z",
367
+ durationValue: 7,
368
+ durationUnit: "d",
369
+ timeZone: "Asia/Bangkok",
370
+ context: {
371
+ containerValue: 30, // 30 tabs per bottle
372
+ containerUnit: "tab"
373
+ }
374
+ });
375
+
376
+ // → { totalUnits: 21, totalContainers: 1 }
377
+ ```
378
+
379
+ It can also handle strength-based conversions (e.g. calculating how many 100mL bottles are needed for a 500mg TID dose of a 250mg/5mL suspension).
380
+
381
+ ### Strength parsing
382
+
383
+ Use `parseStrength` to normalize medication strength strings into FHIR-compliant **Quantity** or **Ratio** structures. It understands percentages, ratios, and composite strengths.
384
+
385
+ ```ts
386
+ import { parseStrength } from "ezmedicationinput";
387
+
388
+ // Percentage (infers g/100mL for liquids or g/100g for solids)
389
+ parseStrength("1%", { dosageForm: "cream" });
390
+ // → { strengthRatio: { numerator: { value: 1, unit: "g" }, denominator: { value: 100, unit: "g" } } }
391
+
392
+ // Ratios
393
+ parseStrength("250mg/5mL");
394
+ // → { strengthRatio: { numerator: { value: 250, unit: "mg" }, denominator: { value: 5, unit: "mL" } } }
395
+
396
+ // Composite (sums components into a single ratio)
397
+ parseStrength("875mg + 125mg");
398
+ // → { strengthQuantity: { value: 1000, unit: "mg" } }
399
+
400
+ // Simple Quantity
401
+ parseStrength("500mg");
402
+ // → { strengthQuantity: { value: 500, unit: "mg" } }
403
+ ```
404
+
405
+ `parseStrengthIntoRatio` is also available if you specifically need a FHIR Ratio object regardless of the denominator.
406
+
355
407
  ### Ocular & intravitreal shortcuts
356
408
 
357
409
  The parser recognizes ophthalmic shorthands such as `OD`, `OS`, `OU`, `LE`, `RE`, and `BE`, as well as intravitreal-specific tokens including `IVT`, `IVTOD`, `IVTOS`, `IVTLE`, `IVTBE`, `VOD`, and `VOS`. Intravitreal sigs require an eye side; the parser surfaces a warning if one is missing so downstream workflows can prompt the clinician for clarification.
package/dist/fhir.js CHANGED
@@ -9,7 +9,7 @@ const object_1 = require("./utils/object");
9
9
  const array_1 = require("./utils/array");
10
10
  const SNOMED_SYSTEM = "http://snomed.info/sct";
11
11
  function toFhir(internal) {
12
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
12
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
13
13
  const dosage = {};
14
14
  const repeat = {};
15
15
  let hasRepeat = false;
@@ -42,6 +42,10 @@ function toFhir(internal) {
42
42
  repeat.when = [...internal.when];
43
43
  hasRepeat = true;
44
44
  }
45
+ if ((_a = internal.timeOfDay) === null || _a === void 0 ? void 0 : _a.length) {
46
+ repeat.timeOfDay = [...internal.timeOfDay];
47
+ hasRepeat = true;
48
+ }
45
49
  if (hasRepeat) {
46
50
  dosage.timing = { repeat };
47
51
  }
@@ -49,7 +53,7 @@ function toFhir(internal) {
49
53
  dosage.timing = {};
50
54
  }
51
55
  if (internal.timingCode) {
52
- dosage.timing = (_a = dosage.timing) !== null && _a !== void 0 ? _a : {};
56
+ dosage.timing = (_b = dosage.timing) !== null && _b !== void 0 ? _b : {};
53
57
  dosage.timing.code = {
54
58
  coding: [{ code: internal.timingCode }],
55
59
  text: internal.timingCode
@@ -82,7 +86,7 @@ function toFhir(internal) {
82
86
  // Emit SNOMED-coded routes whenever we have parsed or inferred route data.
83
87
  if (internal.routeCode || internal.routeText) {
84
88
  const coding = internal.routeCode ? maps_1.ROUTE_SNOMED[internal.routeCode] : undefined;
85
- const text = (_b = internal.routeText) !== null && _b !== void 0 ? _b : (internal.routeCode ? maps_1.ROUTE_TEXT[internal.routeCode] : undefined);
89
+ const text = (_c = internal.routeText) !== null && _c !== void 0 ? _c : (internal.routeCode ? maps_1.ROUTE_TEXT[internal.routeCode] : undefined);
86
90
  if (coding) {
87
91
  // Provide both text and coding so human-readable and coded systems align.
88
92
  dosage.route = {
@@ -100,11 +104,11 @@ function toFhir(internal) {
100
104
  dosage.route = { text };
101
105
  }
102
106
  }
103
- if (internal.siteText || ((_c = internal.siteCoding) === null || _c === void 0 ? void 0 : _c.code)) {
104
- const coding = ((_d = internal.siteCoding) === null || _d === void 0 ? void 0 : _d.code)
107
+ if (internal.siteText || ((_d = internal.siteCoding) === null || _d === void 0 ? void 0 : _d.code)) {
108
+ const coding = ((_e = internal.siteCoding) === null || _e === void 0 ? void 0 : _e.code)
105
109
  ? [
106
110
  {
107
- system: (_e = internal.siteCoding.system) !== null && _e !== void 0 ? _e : SNOMED_SYSTEM,
111
+ system: (_f = internal.siteCoding.system) !== null && _f !== void 0 ? _f : SNOMED_SYSTEM,
108
112
  code: internal.siteCoding.code,
109
113
  display: internal.siteCoding.display
110
114
  }
@@ -115,7 +119,7 @@ function toFhir(internal) {
115
119
  coding
116
120
  };
117
121
  }
118
- if ((_f = internal.additionalInstructions) === null || _f === void 0 ? void 0 : _f.length) {
122
+ if ((_g = internal.additionalInstructions) === null || _g === void 0 ? void 0 : _g.length) {
119
123
  dosage.additionalInstruction = internal.additionalInstructions.map((instruction) => {
120
124
  var _a, _b;
121
125
  return ({
@@ -134,15 +138,15 @@ function toFhir(internal) {
134
138
  }
135
139
  if (internal.asNeeded) {
136
140
  dosage.asNeededBoolean = true;
137
- if (internal.asNeededReason || ((_g = internal.asNeededReasonCoding) === null || _g === void 0 ? void 0 : _g.code)) {
141
+ if (internal.asNeededReason || ((_h = internal.asNeededReasonCoding) === null || _h === void 0 ? void 0 : _h.code)) {
138
142
  const concept = {};
139
143
  if (internal.asNeededReason) {
140
144
  concept.text = internal.asNeededReason;
141
145
  }
142
- if ((_h = internal.asNeededReasonCoding) === null || _h === void 0 ? void 0 : _h.code) {
146
+ if ((_j = internal.asNeededReasonCoding) === null || _j === void 0 ? void 0 : _j.code) {
143
147
  concept.coding = [
144
148
  {
145
- system: (_j = internal.asNeededReasonCoding.system) !== null && _j !== void 0 ? _j : SNOMED_SYSTEM,
149
+ system: (_k = internal.asNeededReasonCoding.system) !== null && _k !== void 0 ? _k : SNOMED_SYSTEM,
146
150
  code: internal.asNeededReasonCoding.code,
147
151
  display: internal.asNeededReasonCoding.display
148
152
  }
@@ -158,7 +162,7 @@ function toFhir(internal) {
158
162
  return dosage;
159
163
  }
160
164
  function internalFromFhir(dosage) {
161
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11;
165
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13;
162
166
  const internal = {
163
167
  input: (_a = dosage.text) !== null && _a !== void 0 ? _a : "",
164
168
  tokens: [],
@@ -169,24 +173,27 @@ function internalFromFhir(dosage) {
169
173
  when: ((_e = (_d = dosage.timing) === null || _d === void 0 ? void 0 : _d.repeat) === null || _e === void 0 ? void 0 : _e.when)
170
174
  ? dosage.timing.repeat.when.filter((value) => (0, array_1.arrayIncludes)((0, object_1.objectValues)(types_1.EventTiming), value))
171
175
  : [],
176
+ timeOfDay: ((_g = (_f = dosage.timing) === null || _f === void 0 ? void 0 : _f.repeat) === null || _g === void 0 ? void 0 : _g.timeOfDay)
177
+ ? [...dosage.timing.repeat.timeOfDay]
178
+ : [],
172
179
  warnings: [],
173
- timingCode: (_j = (_h = (_g = (_f = dosage.timing) === null || _f === void 0 ? void 0 : _f.code) === null || _g === void 0 ? void 0 : _g.coding) === null || _h === void 0 ? void 0 : _h[0]) === null || _j === void 0 ? void 0 : _j.code,
174
- count: (_l = (_k = dosage.timing) === null || _k === void 0 ? void 0 : _k.repeat) === null || _l === void 0 ? void 0 : _l.count,
175
- frequency: (_o = (_m = dosage.timing) === null || _m === void 0 ? void 0 : _m.repeat) === null || _o === void 0 ? void 0 : _o.frequency,
176
- frequencyMax: (_q = (_p = dosage.timing) === null || _p === void 0 ? void 0 : _p.repeat) === null || _q === void 0 ? void 0 : _q.frequencyMax,
177
- period: (_s = (_r = dosage.timing) === null || _r === void 0 ? void 0 : _r.repeat) === null || _s === void 0 ? void 0 : _s.period,
178
- periodMax: (_u = (_t = dosage.timing) === null || _t === void 0 ? void 0 : _t.repeat) === null || _u === void 0 ? void 0 : _u.periodMax,
179
- periodUnit: (_w = (_v = dosage.timing) === null || _v === void 0 ? void 0 : _v.repeat) === null || _w === void 0 ? void 0 : _w.periodUnit,
180
- routeText: (_x = dosage.route) === null || _x === void 0 ? void 0 : _x.text,
181
- siteText: (_y = dosage.site) === null || _y === void 0 ? void 0 : _y.text,
180
+ timingCode: (_l = (_k = (_j = (_h = dosage.timing) === null || _h === void 0 ? void 0 : _h.code) === null || _j === void 0 ? void 0 : _j.coding) === null || _k === void 0 ? void 0 : _k[0]) === null || _l === void 0 ? void 0 : _l.code,
181
+ count: (_o = (_m = dosage.timing) === null || _m === void 0 ? void 0 : _m.repeat) === null || _o === void 0 ? void 0 : _o.count,
182
+ frequency: (_q = (_p = dosage.timing) === null || _p === void 0 ? void 0 : _p.repeat) === null || _q === void 0 ? void 0 : _q.frequency,
183
+ frequencyMax: (_s = (_r = dosage.timing) === null || _r === void 0 ? void 0 : _r.repeat) === null || _s === void 0 ? void 0 : _s.frequencyMax,
184
+ period: (_u = (_t = dosage.timing) === null || _t === void 0 ? void 0 : _t.repeat) === null || _u === void 0 ? void 0 : _u.period,
185
+ periodMax: (_w = (_v = dosage.timing) === null || _v === void 0 ? void 0 : _v.repeat) === null || _w === void 0 ? void 0 : _w.periodMax,
186
+ periodUnit: (_y = (_x = dosage.timing) === null || _x === void 0 ? void 0 : _x.repeat) === null || _y === void 0 ? void 0 : _y.periodUnit,
187
+ routeText: (_z = dosage.route) === null || _z === void 0 ? void 0 : _z.text,
188
+ siteText: (_0 = dosage.site) === null || _0 === void 0 ? void 0 : _0.text,
182
189
  asNeeded: dosage.asNeededBoolean,
183
- asNeededReason: (_0 = (_z = dosage.asNeededFor) === null || _z === void 0 ? void 0 : _z[0]) === null || _0 === void 0 ? void 0 : _0.text,
190
+ asNeededReason: (_2 = (_1 = dosage.asNeededFor) === null || _1 === void 0 ? void 0 : _1[0]) === null || _2 === void 0 ? void 0 : _2.text,
184
191
  siteTokenIndices: new Set(),
185
192
  siteLookups: [],
186
193
  prnReasonLookups: [],
187
194
  additionalInstructions: []
188
195
  };
189
- const routeCoding = (_2 = (_1 = dosage.route) === null || _1 === void 0 ? void 0 : _1.coding) === null || _2 === void 0 ? void 0 : _2.find((code) => code.system === SNOMED_SYSTEM);
196
+ const routeCoding = (_4 = (_3 = dosage.route) === null || _3 === void 0 ? void 0 : _3.coding) === null || _4 === void 0 ? void 0 : _4.find((code) => code.system === SNOMED_SYSTEM);
190
197
  if (routeCoding === null || routeCoding === void 0 ? void 0 : routeCoding.code) {
191
198
  // Translate SNOMED codings back into the simplified enum for round-trip fidelity.
192
199
  const mapped = maps_1.ROUTE_BY_SNOMED[routeCoding.code];
@@ -195,7 +202,7 @@ function internalFromFhir(dosage) {
195
202
  internal.routeText = maps_1.ROUTE_TEXT[mapped];
196
203
  }
197
204
  }
198
- const siteCoding = (_4 = (_3 = dosage.site) === null || _3 === void 0 ? void 0 : _3.coding) === null || _4 === void 0 ? void 0 : _4.find((code) => code.system === SNOMED_SYSTEM);
205
+ const siteCoding = (_6 = (_5 = dosage.site) === null || _5 === void 0 ? void 0 : _5.coding) === null || _6 === void 0 ? void 0 : _6.find((code) => code.system === SNOMED_SYSTEM);
199
206
  if (siteCoding === null || siteCoding === void 0 ? void 0 : siteCoding.code) {
200
207
  internal.siteCoding = {
201
208
  code: siteCoding.code,
@@ -203,7 +210,7 @@ function internalFromFhir(dosage) {
203
210
  system: siteCoding.system
204
211
  };
205
212
  }
206
- const reasonCoding = (_7 = (_6 = (_5 = dosage.asNeededFor) === null || _5 === void 0 ? void 0 : _5[0]) === null || _6 === void 0 ? void 0 : _6.coding) === null || _7 === void 0 ? void 0 : _7[0];
213
+ const reasonCoding = (_9 = (_8 = (_7 = dosage.asNeededFor) === null || _7 === void 0 ? void 0 : _7[0]) === null || _8 === void 0 ? void 0 : _8.coding) === null || _9 === void 0 ? void 0 : _9[0];
207
214
  if (reasonCoding === null || reasonCoding === void 0 ? void 0 : reasonCoding.code) {
208
215
  internal.asNeededReasonCoding = {
209
216
  code: reasonCoding.code,
@@ -211,7 +218,7 @@ function internalFromFhir(dosage) {
211
218
  system: reasonCoding.system
212
219
  };
213
220
  }
214
- if ((_8 = dosage.additionalInstruction) === null || _8 === void 0 ? void 0 : _8.length) {
221
+ if ((_10 = dosage.additionalInstruction) === null || _10 === void 0 ? void 0 : _10.length) {
215
222
  internal.additionalInstructions = dosage.additionalInstruction.map((concept) => {
216
223
  var _a;
217
224
  return ({
@@ -226,13 +233,13 @@ function internalFromFhir(dosage) {
226
233
  });
227
234
  });
228
235
  }
229
- const doseAndRate = (_9 = dosage.doseAndRate) === null || _9 === void 0 ? void 0 : _9[0];
236
+ const doseAndRate = (_11 = dosage.doseAndRate) === null || _11 === void 0 ? void 0 : _11[0];
230
237
  if (doseAndRate === null || doseAndRate === void 0 ? void 0 : doseAndRate.doseRange) {
231
238
  const { low, high } = doseAndRate.doseRange;
232
239
  if ((low === null || low === void 0 ? void 0 : low.value) !== undefined && (high === null || high === void 0 ? void 0 : high.value) !== undefined) {
233
240
  internal.doseRange = { low: low.value, high: high.value };
234
241
  }
235
- internal.unit = (_11 = (_10 = low === null || low === void 0 ? void 0 : low.unit) !== null && _10 !== void 0 ? _10 : high === null || high === void 0 ? void 0 : high.unit) !== null && _11 !== void 0 ? _11 : internal.unit;
242
+ internal.unit = (_13 = (_12 = low === null || low === void 0 ? void 0 : low.unit) !== null && _12 !== void 0 ? _12 : high === null || high === void 0 ? void 0 : high.unit) !== null && _13 !== void 0 ? _13 : internal.unit;
236
243
  }
237
244
  else if (doseAndRate === null || doseAndRate === void 0 ? void 0 : doseAndRate.doseQuantity) {
238
245
  const dose = doseAndRate.doseQuantity;
package/dist/format.js CHANGED
@@ -515,6 +515,7 @@ function formatInternal(internal, style, localization) {
515
515
  return defaults[style];
516
516
  }
517
517
  function formatShort(internal) {
518
+ var _a;
518
519
  const parts = [];
519
520
  const dosePart = formatDoseShort(internal);
520
521
  if (dosePart) {
@@ -561,6 +562,9 @@ function formatShort(internal) {
561
562
  .map((d) => d.charAt(0).toUpperCase() + d.slice(1, 3))
562
563
  .join(","));
563
564
  }
565
+ if ((_a = internal.timeOfDay) === null || _a === void 0 ? void 0 : _a.length) {
566
+ parts.push(internal.timeOfDay.map(t => t.slice(0, 5)).join(","));
567
+ }
564
568
  if (internal.count !== undefined) {
565
569
  parts.push(`x${stripTrailingZero(internal.count)}`);
566
570
  }
@@ -575,13 +579,24 @@ function formatShort(internal) {
575
579
  return parts.filter(Boolean).join(" ");
576
580
  }
577
581
  function formatLong(internal) {
578
- var _a;
582
+ var _a, _b;
579
583
  const grammar = resolveRouteGrammar(internal);
580
584
  const dosePart = (_a = formatDoseLong(internal)) !== null && _a !== void 0 ? _a : "the medication";
581
585
  const sitePart = formatSite(internal, grammar);
582
586
  const routePart = buildRoutePhrase(internal, grammar, Boolean(sitePart));
583
587
  const frequencyPart = describeFrequency(internal);
584
588
  const eventParts = collectWhenPhrases(internal);
589
+ if ((_b = internal.timeOfDay) === null || _b === void 0 ? void 0 : _b.length) {
590
+ for (const time of internal.timeOfDay) {
591
+ const parts = time.split(":");
592
+ const h = parseInt(parts[0], 10);
593
+ const m = parseInt(parts[1], 10);
594
+ const isAm = h < 12;
595
+ const displayH = h % 12 || 12;
596
+ const displayM = m < 10 ? `0${m}` : `${m}`;
597
+ eventParts.push(`at ${displayH}:${displayM}${isAm ? " am" : " pm"}`);
598
+ }
599
+ }
585
600
  const timing = combineFrequencyAndEvents(frequencyPart, eventParts);
586
601
  const dayPart = describeDayOfWeek(internal);
587
602
  const countPart = internal.count
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ import { FhirDosage, FormatOptions, LintResult, ParseOptions, ParseResult } from
2
2
  export { parseInternal } from "./parser";
3
3
  export { suggestSig } from "./suggest";
4
4
  export * from "./types";
5
- export { nextDueDoses } from "./schedule";
5
+ export { nextDueDoses, calculateTotalUnits } from "./schedule";
6
+ export { parseStrength, parseStrengthIntoRatio } from "./utils/strength";
6
7
  export { getRegisteredSigLocalizations, registerSigLocalization, resolveSigLocalization, resolveSigTranslation } from "./i18n";
7
8
  export type { SigLocalization, SigLocalizationConfig, SigTranslation, SigTranslationConfig } from "./i18n";
8
9
  export declare function parseSig(input: string, options?: ParseOptions): ParseResult;
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
23
23
  });
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.resolveSigTranslation = exports.resolveSigLocalization = exports.registerSigLocalization = exports.getRegisteredSigLocalizations = exports.nextDueDoses = exports.suggestSig = exports.parseInternal = void 0;
26
+ exports.resolveSigTranslation = exports.resolveSigLocalization = exports.registerSigLocalization = exports.getRegisteredSigLocalizations = exports.parseStrengthIntoRatio = exports.parseStrength = exports.calculateTotalUnits = exports.nextDueDoses = exports.suggestSig = exports.parseInternal = void 0;
27
27
  exports.parseSig = parseSig;
28
28
  exports.lintSig = lintSig;
29
29
  exports.parseSigAsync = parseSigAsync;
@@ -40,6 +40,10 @@ Object.defineProperty(exports, "suggestSig", { enumerable: true, get: function (
40
40
  __exportStar(require("./types"), exports);
41
41
  var schedule_1 = require("./schedule");
42
42
  Object.defineProperty(exports, "nextDueDoses", { enumerable: true, get: function () { return schedule_1.nextDueDoses; } });
43
+ Object.defineProperty(exports, "calculateTotalUnits", { enumerable: true, get: function () { return schedule_1.calculateTotalUnits; } });
44
+ var strength_1 = require("./utils/strength");
45
+ Object.defineProperty(exports, "parseStrength", { enumerable: true, get: function () { return strength_1.parseStrength; } });
46
+ Object.defineProperty(exports, "parseStrengthIntoRatio", { enumerable: true, get: function () { return strength_1.parseStrengthIntoRatio; } });
43
47
  var i18n_2 = require("./i18n");
44
48
  Object.defineProperty(exports, "getRegisteredSigLocalizations", { enumerable: true, get: function () { return i18n_2.getRegisteredSigLocalizations; } });
45
49
  Object.defineProperty(exports, "registerSigLocalization", { enumerable: true, get: function () { return i18n_2.registerSigLocalization; } });
@@ -32,6 +32,7 @@ export interface ParsedSigInternal {
32
32
  periodUnit?: FhirPeriodUnit;
33
33
  dayOfWeek: FhirDayOfWeek[];
34
34
  when: EventTiming[];
35
+ timeOfDay?: string[];
35
36
  timingCode?: string;
36
37
  asNeeded?: boolean;
37
38
  asNeededReason?: string;
package/dist/parser.js CHANGED
@@ -749,6 +749,114 @@ function tryParseCountBasedFrequency(internal, tokens, index, options) {
749
749
  }
750
750
  return consumeCurrentToken;
751
751
  }
752
+ function parseTimeToFhir(timeStr) {
753
+ const clean = timeStr.toLowerCase().trim();
754
+ // Match 9:00, 9.00, 9:00am, 9pm, 9 am, 9
755
+ const match = clean.match(/^(\d{1,2})[:.](\d{2})\s*(am|pm)?$/) ||
756
+ clean.match(/^(\d{1,2})\s*(am|pm)$/) ||
757
+ clean.match(/^(\d{1,2})$/);
758
+ if (!match)
759
+ return undefined;
760
+ let hour = parseInt(match[1], 10);
761
+ let minute = 0;
762
+ let ampm;
763
+ if (match[2] && !isNaN(parseInt(match[2], 10))) {
764
+ minute = parseInt(match[2], 10);
765
+ ampm = match[3];
766
+ }
767
+ else {
768
+ ampm = match[2];
769
+ }
770
+ if (ampm === "pm" && hour < 12)
771
+ hour += 12;
772
+ if (ampm === "am" && hour === 12)
773
+ hour = 0;
774
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
775
+ return undefined;
776
+ const h = hour < 10 ? `0${hour}` : `${hour}`;
777
+ const m = minute < 10 ? `0${minute}` : `${minute}`;
778
+ return `${h}:${m}:00`;
779
+ }
780
+ function tryParseTimeBasedSchedule(internal, tokens, index) {
781
+ const token = tokens[index];
782
+ if (internal.consumed.has(token.index))
783
+ return false;
784
+ const isAtPrefix = token.lower === "@" || token.lower === "at";
785
+ if (!isAtPrefix && !/^\d/.test(token.lower))
786
+ return false;
787
+ let nextIndex = isAtPrefix ? index + 1 : index;
788
+ const times = [];
789
+ const consumedIndices = [];
790
+ const timeTokens = [];
791
+ if (isAtPrefix)
792
+ consumedIndices.push(index);
793
+ while (nextIndex < tokens.length) {
794
+ const nextToken = tokens[nextIndex];
795
+ if (!nextToken || internal.consumed.has(nextToken.index))
796
+ break;
797
+ let timeStr = nextToken.lower;
798
+ let lookaheadIndices = [];
799
+ // Look ahead for am/pm if current token is just a number or doesn't have am/pm
800
+ if (!timeStr.includes("am") && !timeStr.includes("pm")) {
801
+ const nextNext = tokens[nextIndex + 1];
802
+ if (nextNext && !internal.consumed.has(nextNext.index) && (nextNext.lower === "am" || nextNext.lower === "pm")) {
803
+ timeStr += nextNext.lower;
804
+ lookaheadIndices.push(nextIndex + 1);
805
+ }
806
+ }
807
+ const time = parseTimeToFhir(timeStr);
808
+ if (time) {
809
+ times.push(time);
810
+ timeTokens.push(timeStr);
811
+ consumedIndices.push(nextIndex);
812
+ for (const idx of lookaheadIndices) {
813
+ consumedIndices.push(idx);
814
+ }
815
+ nextIndex += 1 + lookaheadIndices.length;
816
+ // Support comma or space separated times
817
+ const separatorToken = tokens[nextIndex];
818
+ // Check if there is another time after the separator
819
+ if (separatorToken && (separatorToken.lower === "," || separatorToken.lower === "and")) {
820
+ // Peek for next time
821
+ let peekIndex = nextIndex + 1;
822
+ let peekToken = tokens[peekIndex];
823
+ if (peekToken) {
824
+ let peekStr = peekToken.lower;
825
+ let peekNext = tokens[peekIndex + 1];
826
+ if (peekNext && !internal.consumed.has(peekNext.index) && (peekNext.lower === "am" || peekNext.lower === "pm")) {
827
+ peekStr += peekNext.lower;
828
+ }
829
+ if (parseTimeToFhir(peekStr)) {
830
+ consumedIndices.push(nextIndex);
831
+ nextIndex++;
832
+ continue;
833
+ }
834
+ }
835
+ }
836
+ continue;
837
+ }
838
+ break;
839
+ }
840
+ if (times.length > 0) {
841
+ if (!isAtPrefix) {
842
+ const hasClearTimeFormat = timeTokens.some((t) => t.includes(":") || t.includes("am") || t.includes("pm"));
843
+ if (!hasClearTimeFormat) {
844
+ return false;
845
+ }
846
+ }
847
+ internal.timeOfDay = internal.timeOfDay || [];
848
+ for (const time of times) {
849
+ if (!(0, array_1.arrayIncludes)(internal.timeOfDay, time)) {
850
+ internal.timeOfDay.push(time);
851
+ }
852
+ }
853
+ for (const idx of consumedIndices) {
854
+ mark(internal.consumed, tokens[idx]);
855
+ }
856
+ return true;
857
+ }
858
+ return false;
859
+ }
752
860
  const SITE_UNIT_ROUTE_HINTS = [
753
861
  { pattern: /\beye(s)?\b/i, route: types_1.RouteCode["Ophthalmic route"] },
754
862
  { pattern: /\beyelid(s)?\b/i, route: types_1.RouteCode["Ophthalmic route"] },
@@ -1385,7 +1493,8 @@ function applyWhenToken(internal, token, code) {
1385
1493
  addWhen(internal.when, code);
1386
1494
  mark(internal.consumed, token);
1387
1495
  }
1388
- function parseMealContext(internal, tokens, index, code) {
1496
+ function parseAnchorSequence(internal, tokens, index, prefixCode) {
1497
+ var _a;
1389
1498
  const token = tokens[index];
1390
1499
  let converted = 0;
1391
1500
  for (let lookahead = index + 1; lookahead < tokens.length; lookahead++) {
@@ -1393,30 +1502,57 @@ function parseMealContext(internal, tokens, index, code) {
1393
1502
  if (internal.consumed.has(nextToken.index)) {
1394
1503
  continue;
1395
1504
  }
1396
- if (MEAL_CONTEXT_CONNECTORS.has(nextToken.lower)) {
1505
+ const lower = nextToken.lower;
1506
+ if (MEAL_CONTEXT_CONNECTORS.has(lower) || lower === ",") {
1397
1507
  mark(internal.consumed, nextToken);
1398
1508
  continue;
1399
1509
  }
1400
- const meal = maps_1.MEAL_KEYWORDS[nextToken.lower];
1401
- if (!meal) {
1402
- break;
1510
+ const day = maps_1.DAY_OF_WEEK_TOKENS[lower];
1511
+ if (day) {
1512
+ if (!(0, array_1.arrayIncludes)(internal.dayOfWeek, day)) {
1513
+ internal.dayOfWeek.push(day);
1514
+ }
1515
+ mark(internal.consumed, nextToken);
1516
+ converted++;
1517
+ continue;
1518
+ }
1519
+ const meal = maps_1.MEAL_KEYWORDS[lower];
1520
+ if (meal) {
1521
+ const whenCode = prefixCode === types_1.EventTiming["After Meal"]
1522
+ ? meal.pc
1523
+ : prefixCode === types_1.EventTiming["Before Meal"]
1524
+ ? meal.ac
1525
+ : ((_a = maps_1.EVENT_TIMING_TOKENS[lower]) !== null && _a !== void 0 ? _a : meal.pc); // fallback to general or conservative default
1526
+ addWhen(internal.when, whenCode);
1527
+ mark(internal.consumed, nextToken);
1528
+ converted++;
1529
+ continue;
1530
+ }
1531
+ const whenCode = maps_1.EVENT_TIMING_TOKENS[lower];
1532
+ if (whenCode) {
1533
+ if (prefixCode && !meal) {
1534
+ // if we have pc/ac, we only want to follow it with explicit meals
1535
+ // to avoid over-consuming anchors that should be separate (like 'pc hs')
1536
+ break;
1537
+ }
1538
+ addWhen(internal.when, whenCode);
1539
+ mark(internal.consumed, nextToken);
1540
+ converted++;
1541
+ continue;
1403
1542
  }
1404
- const whenCode = code === types_1.EventTiming["After Meal"]
1405
- ? meal.pc
1406
- : code === types_1.EventTiming["Before Meal"]
1407
- ? meal.ac
1408
- : code;
1409
- addWhen(internal.when, whenCode);
1410
- mark(internal.consumed, nextToken);
1411
- converted++;
1543
+ break;
1412
1544
  }
1413
1545
  if (converted > 0) {
1414
1546
  mark(internal.consumed, token);
1415
- return;
1547
+ return true;
1548
+ }
1549
+ if (prefixCode) {
1550
+ applyWhenToken(internal, token, prefixCode);
1551
+ return true;
1416
1552
  }
1417
- applyWhenToken(internal, token, code);
1553
+ return false;
1418
1554
  }
1419
- function parseSeparatedQ(internal, tokens, index, options) {
1555
+ function parseSeparatedInterval(internal, tokens, index, options) {
1420
1556
  const token = tokens[index];
1421
1557
  const next = tokens[index + 1];
1422
1558
  if (!next || internal.consumed.has(next.index)) {
@@ -1736,11 +1872,14 @@ function parseInternal(input, options) {
1736
1872
  applyWhenToken(internal, token, types_1.EventTiming.Meal);
1737
1873
  continue;
1738
1874
  }
1739
- if (token.lower === "q") {
1740
- if (parseSeparatedQ(internal, tokens, i, options)) {
1875
+ if (token.lower === "q" || token.lower === "every" || token.lower === "each") {
1876
+ if (parseSeparatedInterval(internal, tokens, i, options)) {
1741
1877
  continue;
1742
1878
  }
1743
1879
  }
1880
+ if (tryParseTimeBasedSchedule(internal, tokens, i)) {
1881
+ continue;
1882
+ }
1744
1883
  if (tryParseNumericCadence(internal, tokens, i)) {
1745
1884
  continue;
1746
1885
  }
@@ -1768,12 +1907,22 @@ function parseInternal(input, options) {
1768
1907
  continue;
1769
1908
  }
1770
1909
  // Event timing tokens
1771
- if (token.lower === "pc" || token.lower === "ac") {
1772
- parseMealContext(internal, tokens, i, token.lower === "pc"
1910
+ if (token.lower === "pc" || token.lower === "ac" || token.lower === "after" || token.lower === "before") {
1911
+ parseAnchorSequence(internal, tokens, i, (token.lower === "pc" || token.lower === "after")
1773
1912
  ? types_1.EventTiming["After Meal"]
1774
1913
  : types_1.EventTiming["Before Meal"]);
1775
1914
  continue;
1776
1915
  }
1916
+ if (token.lower === "at" || token.lower === "@" || token.lower === "on") {
1917
+ if (parseAnchorSequence(internal, tokens, i)) {
1918
+ continue;
1919
+ }
1920
+ if (tryParseTimeBasedSchedule(internal, tokens, i)) {
1921
+ continue;
1922
+ }
1923
+ mark(internal.consumed, token);
1924
+ continue;
1925
+ }
1777
1926
  const nextToken = tokens[i + 1];
1778
1927
  if (nextToken && !internal.consumed.has(nextToken.index)) {
1779
1928
  const combo = `${token.lower} ${nextToken.lower}`;
@@ -1,6 +1,7 @@
1
- import { FhirDosage, NextDueDoseOptions } from "./types";
1
+ import { FhirDosage, NextDueDoseOptions, TotalUnitsOptions, TotalUnitsResult } from "./types";
2
2
  /**
3
3
  * Produces the next dose timestamps in ascending order according to the
4
4
  * provided configuration and dosage metadata.
5
5
  */
6
6
  export declare function nextDueDoses(dosage: FhirDosage, options: NextDueDoseOptions): string[];
7
+ export declare function calculateTotalUnits(options: TotalUnitsOptions): TotalUnitsResult;
package/dist/schedule.js CHANGED
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.nextDueDoses = nextDueDoses;
4
+ exports.calculateTotalUnits = calculateTotalUnits;
4
5
  const types_1 = require("./types");
5
6
  const array_1 = require("./utils/array");
7
+ const units_1 = require("./utils/units");
8
+ const strength_1 = require("./utils/strength");
6
9
  /**
7
10
  * Default institution times used when a dosage only specifies frequency without
8
11
  * explicit EventTiming anchors. Clinics can override these through the
@@ -228,8 +231,12 @@ function makeZonedDate(timeZone, year, month, day, hour, minute, second) {
228
231
  }
229
232
  /** Convenience wrapper around makeZonedDate for day-level math. */
230
233
  function makeZonedDateFromDay(base, timeZone, clock) {
234
+ var _a, _b, _c;
231
235
  const { year, month, day } = getTimeParts(base, timeZone);
232
- const [hour, minute, second] = clock.split(":").map((value) => Number(value));
236
+ const parts = clock.split(":").map((value) => Number(value));
237
+ const hour = (_a = parts[0]) !== null && _a !== void 0 ? _a : 0;
238
+ const minute = (_b = parts[1]) !== null && _b !== void 0 ? _b : 0;
239
+ const second = (_c = parts[2]) !== null && _c !== void 0 ? _c : 0;
233
240
  return makeZonedDate(timeZone, year, month, day, hour, minute, second);
234
241
  }
235
242
  /** Returns a Date pinned to the start of the local day. */
@@ -244,7 +251,8 @@ function startOfLocalDay(date, timeZone) {
244
251
  /** Adds a number of calendar days while remaining aligned to the time zone. */
245
252
  function addLocalDays(date, days, timeZone) {
246
253
  const { year, month, day } = getTimeParts(date, timeZone);
247
- const zoned = makeZonedDate(timeZone, year, month, day + days, 0, 0, 0);
254
+ const rollover = new Date(Date.UTC(year, month - 1, day + days));
255
+ const zoned = makeZonedDate(timeZone, rollover.getUTCFullYear(), rollover.getUTCMonth() + 1, rollover.getUTCDate(), 0, 0, 0);
248
256
  if (!zoned) {
249
257
  throw new Error("Unable to shift local day – invalid calendar combination");
250
258
  }
@@ -895,3 +903,202 @@ function addCalendarMonths(date, months, timeZone) {
895
903
  }
896
904
  return final;
897
905
  }
906
+ /**
907
+ * Internal helper to count dose events within a time range.
908
+ */
909
+ function countScheduleEvents(dosage, from, to, config, baseTime, orderedAt, limit) {
910
+ var _a, _b, _c;
911
+ const timing = dosage.timing;
912
+ const repeat = timing === null || timing === void 0 ? void 0 : timing.repeat;
913
+ if (!timing || !repeat)
914
+ return 0;
915
+ const dayFilter = new Set(((_a = repeat.dayOfWeek) !== null && _a !== void 0 ? _a : []).map((day) => day.toLowerCase()));
916
+ const enforceDayFilter = dayFilter.size > 0;
917
+ const seen = new Set();
918
+ let count = 0;
919
+ const timeZone = config.timeZone;
920
+ const recordCandidate = (candidate) => {
921
+ if (!candidate)
922
+ return false;
923
+ if (candidate < from || candidate >= to)
924
+ return false;
925
+ const iso = formatZonedIso(candidate, timeZone);
926
+ if (seen.has(iso))
927
+ return false;
928
+ seen.add(iso);
929
+ count += 1;
930
+ return true;
931
+ };
932
+ const whenCodes = (_b = repeat.when) !== null && _b !== void 0 ? _b : [];
933
+ const timeOfDayEntries = (_c = repeat.timeOfDay) !== null && _c !== void 0 ? _c : [];
934
+ if (whenCodes.length > 0 || timeOfDayEntries.length > 0) {
935
+ const expanded = expandWhenCodes(whenCodes, config, repeat);
936
+ if (timeOfDayEntries.length > 0) {
937
+ for (const clock of timeOfDayEntries) {
938
+ expanded.push({ time: normalizeClock(clock), dayShift: 0 });
939
+ }
940
+ expanded.sort((a, b) => {
941
+ if (a.dayShift !== b.dayShift)
942
+ return a.dayShift - b.dayShift;
943
+ return a.time.localeCompare(b.time);
944
+ });
945
+ }
946
+ if ((0, array_1.arrayIncludes)(whenCodes, types_1.EventTiming.Immediate)) {
947
+ const immediateSource = orderedAt !== null && orderedAt !== void 0 ? orderedAt : from;
948
+ if (!orderedAt || orderedAt >= from) {
949
+ recordCandidate(immediateSource);
950
+ }
951
+ }
952
+ if (expanded.length === 0)
953
+ return count;
954
+ let currentDay = startOfLocalDay(from, timeZone);
955
+ let iterations = 0;
956
+ const maxIterations = limit !== undefined ? limit * 31 : 365 * 31;
957
+ while (count < (limit !== null && limit !== void 0 ? limit : Infinity) && currentDay < to && iterations < maxIterations) {
958
+ const weekday = getLocalWeekday(currentDay, timeZone);
959
+ if (!enforceDayFilter || dayFilter.has(weekday)) {
960
+ for (const entry of expanded) {
961
+ const targetDay = entry.dayShift === 0
962
+ ? currentDay
963
+ : addLocalDays(currentDay, entry.dayShift, timeZone);
964
+ const zoned = makeZonedDateFromDay(targetDay, timeZone, entry.time);
965
+ if (zoned)
966
+ recordCandidate(zoned);
967
+ }
968
+ }
969
+ currentDay = addLocalDays(currentDay, 1, timeZone);
970
+ iterations += 1;
971
+ }
972
+ return count;
973
+ }
974
+ const treatAsInterval = !!repeat.period &&
975
+ !!repeat.periodUnit &&
976
+ (!repeat.frequency ||
977
+ repeat.periodUnit !== "d" ||
978
+ (repeat.frequency === 1 && repeat.period > 1)) &&
979
+ !enforceDayFilter;
980
+ if (treatAsInterval) {
981
+ const increment = createIntervalStepper(repeat, timeZone);
982
+ if (!increment)
983
+ return count;
984
+ let current = baseTime;
985
+ let guard = 0;
986
+ const maxIterations = limit !== undefined ? limit * 1000 : 10000;
987
+ // Advance to "from"
988
+ while (current < from && guard < maxIterations) {
989
+ const next = increment(current);
990
+ if (!next || next.getTime() === current.getTime())
991
+ break;
992
+ current = next;
993
+ guard++;
994
+ }
995
+ while (current < to && count < (limit !== null && limit !== void 0 ? limit : Infinity) && guard < maxIterations) {
996
+ const weekday = getLocalWeekday(current, timeZone);
997
+ if (!enforceDayFilter || dayFilter.has(weekday)) {
998
+ recordCandidate(current);
999
+ }
1000
+ const next = increment(current);
1001
+ if (!next || next.getTime() === current.getTime())
1002
+ break;
1003
+ current = next;
1004
+ guard += 1;
1005
+ }
1006
+ return count;
1007
+ }
1008
+ if (repeat.frequency && repeat.period && repeat.periodUnit) {
1009
+ const clocks = resolveFrequencyClocks(timing, config);
1010
+ if (clocks.length === 0)
1011
+ return count;
1012
+ let currentDay = startOfLocalDay(from, timeZone);
1013
+ let iterations = 0;
1014
+ const maxIterations = limit !== undefined ? limit * 31 : 365 * 31;
1015
+ while (count < (limit !== null && limit !== void 0 ? limit : Infinity) && currentDay < to && iterations < maxIterations) {
1016
+ const weekday = getLocalWeekday(currentDay, timeZone);
1017
+ if (!enforceDayFilter || dayFilter.has(weekday)) {
1018
+ for (const clock of clocks) {
1019
+ const zoned = makeZonedDateFromDay(currentDay, timeZone, clock);
1020
+ if (zoned)
1021
+ recordCandidate(zoned);
1022
+ }
1023
+ }
1024
+ currentDay = addLocalDays(currentDay, 1, timeZone);
1025
+ iterations += 1;
1026
+ }
1027
+ }
1028
+ // Fallback for dayOfWeek with period/periodUnit but no explicit frequency/clocks
1029
+ if (enforceDayFilter && repeat.period && repeat.periodUnit) {
1030
+ const clocks = ["08:00:00"]; // Default to morning if no times specified
1031
+ let currentDayIter = startOfLocalDay(from, timeZone);
1032
+ let iter = 0;
1033
+ const maxIter = limit !== undefined ? limit * 31 : 365 * 31;
1034
+ while (count < (limit !== null && limit !== void 0 ? limit : Infinity) && currentDayIter < to && iter < maxIter) {
1035
+ const weekday = getLocalWeekday(currentDayIter, timeZone);
1036
+ if (dayFilter.has(weekday)) {
1037
+ for (const clock of clocks) {
1038
+ const zoned = makeZonedDateFromDay(currentDayIter, timeZone, clock);
1039
+ if (zoned)
1040
+ recordCandidate(zoned);
1041
+ }
1042
+ }
1043
+ currentDayIter = addLocalDays(currentDayIter, 1, timeZone);
1044
+ iter += 1;
1045
+ }
1046
+ return count;
1047
+ }
1048
+ return count;
1049
+ }
1050
+ function calculateTotalUnits(options) {
1051
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
1052
+ const { dosage, durationValue, durationUnit, roundToMultiple, context } = options;
1053
+ const from = coerceDate(options.from, "from");
1054
+ const providedConfig = options.config;
1055
+ const timeZone = (_a = options.timeZone) !== null && _a !== void 0 ? _a : providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.timeZone;
1056
+ if (!timeZone) {
1057
+ throw new Error("timeZone is required for calculateTotalUnits");
1058
+ }
1059
+ const eventClock = Object.assign(Object.assign({}, ((_b = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.eventClock) !== null && _b !== void 0 ? _b : {})), ((_c = options.eventClock) !== null && _c !== void 0 ? _c : {}));
1060
+ const mealOffsets = Object.assign(Object.assign({}, ((_d = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.mealOffsets) !== null && _d !== void 0 ? _d : {})), ((_e = options.mealOffsets) !== null && _e !== void 0 ? _e : {}));
1061
+ const frequencyDefaults = mergeFrequencyDefaults(providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.frequencyDefaults, options.frequencyDefaults);
1062
+ const config = {
1063
+ timeZone,
1064
+ eventClock,
1065
+ mealOffsets,
1066
+ frequencyDefaults
1067
+ };
1068
+ // Calculate end date based on duration
1069
+ let endDay;
1070
+ const dummyRepeat = { period: durationValue, periodUnit: durationUnit };
1071
+ const stepper = createIntervalStepper(dummyRepeat, timeZone);
1072
+ if (stepper) {
1073
+ endDay = stepper(from) || from;
1074
+ }
1075
+ else {
1076
+ endDay = from;
1077
+ }
1078
+ const count = countScheduleEvents(dosage, from, endDay, config, options.orderedAt ? coerceDate(options.orderedAt, "orderedAt") : from, options.orderedAt ? coerceDate(options.orderedAt, "orderedAt") : null, 2000);
1079
+ const doseQuantity = (_j = (_h = (_g = (_f = dosage.doseAndRate) === null || _f === void 0 ? void 0 : _f[0]) === null || _g === void 0 ? void 0 : _g.doseQuantity) === null || _h === void 0 ? void 0 : _h.value) !== null && _j !== void 0 ? _j : 0;
1080
+ let totalUnits = count * doseQuantity;
1081
+ if (roundToMultiple && roundToMultiple > 0) {
1082
+ totalUnits = Math.ceil(totalUnits / roundToMultiple) * roundToMultiple;
1083
+ }
1084
+ const result = { totalUnits };
1085
+ // Handle containers
1086
+ const containerValue = context === null || context === void 0 ? void 0 : context.containerValue;
1087
+ const containerUnit = context === null || context === void 0 ? void 0 : context.containerUnit;
1088
+ const doseUnit = (_m = (_l = (_k = dosage.doseAndRate) === null || _k === void 0 ? void 0 : _k[0]) === null || _l === void 0 ? void 0 : _l.doseQuantity) === null || _m === void 0 ? void 0 : _m.unit;
1089
+ if (containerValue && containerValue > 0) {
1090
+ let effectiveUnits = totalUnits;
1091
+ if (containerUnit && doseUnit && containerUnit !== doseUnit) {
1092
+ let strength = context === null || context === void 0 ? void 0 : context.strengthRatio;
1093
+ if (!strength && (context === null || context === void 0 ? void 0 : context.strength)) {
1094
+ strength = (0, strength_1.parseStrengthIntoRatio)(context.strength, context) || undefined;
1095
+ }
1096
+ const converted = (0, units_1.convertValue)(totalUnits, doseUnit, containerUnit, strength);
1097
+ if (converted !== null) {
1098
+ effectiveUnits = converted;
1099
+ }
1100
+ }
1101
+ result.totalContainers = Math.ceil(effectiveUnits / containerValue);
1102
+ }
1103
+ return result;
1104
+ }
package/dist/types.d.ts CHANGED
@@ -279,6 +279,12 @@ export type RouteCode = SNOMEDCTRouteCodes;
279
279
  export declare const RouteCode: typeof SNOMEDCTRouteCodes;
280
280
  export interface MedicationContext {
281
281
  dosageForm?: string;
282
+ /** "Simple" strength string; might be the only way strength is provided
283
+ * for discrete units this is the amount of medication for unit e.g. "500 mg" (1 tablet), or for mixed tablets might be like "400 mg + 80 mg"
284
+ * for things like creams or fluids or syrupsit might be "2%", 5 g/100g, 100mg/ 100 g, 262 mg/15 mL, 200 mg/2 mL, 1 mg/dL, or "400 mg/5mL + 80 mg/5mL"
285
+ * In the "x + y" case, the strengthQuantity/strengthRatio will be the sum of the two ingredients.
286
+ */
287
+ strength?: string;
282
288
  strengthQuantity?: FhirQuantity;
283
289
  strengthRatio?: FhirRatio;
284
290
  strengthCodeableConcept?: FhirCodeableConcept;
@@ -584,3 +590,14 @@ export interface NextDueDoseOptions {
584
590
  frequencyDefaults?: FrequencyFallbackTimes;
585
591
  config?: NextDueDoseConfig;
586
592
  }
593
+ export interface TotalUnitsResult {
594
+ totalUnits: number;
595
+ totalContainers?: number;
596
+ }
597
+ export interface TotalUnitsOptions extends NextDueDoseOptions {
598
+ dosage: FhirDosage;
599
+ durationValue: number;
600
+ durationUnit: FhirPeriodUnit;
601
+ roundToMultiple?: number;
602
+ context?: MedicationContext;
603
+ }
@@ -0,0 +1,12 @@
1
+ import { FhirRatio, FhirQuantity, MedicationContext } from "../types";
2
+ /**
3
+ * High-level strength parser that returns the most appropriate FHIR representation.
4
+ */
5
+ export declare function parseStrength(strength: string, context?: MedicationContext): {
6
+ strengthQuantity?: FhirQuantity;
7
+ strengthRatio?: FhirRatio;
8
+ };
9
+ /**
10
+ * Internal helper to parse a strength string into a FHIR Ratio.
11
+ */
12
+ export declare function parseStrengthIntoRatio(strength: string, context?: MedicationContext): FhirRatio | null;
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStrength = parseStrength;
4
+ exports.parseStrengthIntoRatio = parseStrengthIntoRatio;
5
+ const units_1 = require("./units");
6
+ const maps_1 = require("../maps");
7
+ const array_1 = require("./array");
8
+ /**
9
+ * High-level strength parser that returns the most appropriate FHIR representation.
10
+ */
11
+ function parseStrength(strength, context) {
12
+ var _a, _b, _c;
13
+ const ratio = parseStrengthIntoRatio(strength, context);
14
+ if (!ratio)
15
+ return {};
16
+ if (((_a = ratio.denominator) === null || _a === void 0 ? void 0 : _a.value) === 1 && (((_b = ratio.denominator) === null || _b === void 0 ? void 0 : _b.unit) === "unit" || !((_c = ratio.denominator) === null || _c === void 0 ? void 0 : _c.unit))) {
17
+ return { strengthQuantity: ratio.numerator };
18
+ }
19
+ return { strengthRatio: ratio };
20
+ }
21
+ /**
22
+ * Internal helper to parse a strength string into a FHIR Ratio.
23
+ */
24
+ function parseStrengthIntoRatio(strength, context) {
25
+ var _a, _b, _c, _d, _e;
26
+ const parts = strength.split("+").map((p) => p.trim());
27
+ let totalMgPerMlOrG = 0;
28
+ let hasVolume = false;
29
+ let hasWeightDenominator = false;
30
+ let targetNumUnit;
31
+ let targetDenUnit;
32
+ let targetDenValue;
33
+ for (const part of parts) {
34
+ const ratio = parseSingleStrengthPart(part, context);
35
+ if (!ratio || !((_a = ratio.numerator) === null || _a === void 0 ? void 0 : _a.value) || ratio.numerator.value === 0)
36
+ continue;
37
+ const nUnit = ratio.numerator.unit;
38
+ const dUnit = ((_b = ratio.denominator) === null || _b === void 0 ? void 0 : _b.unit) || "unit";
39
+ const dCat = (0, units_1.getUnitCategory)(dUnit);
40
+ // Save target units from the first part
41
+ if (!targetNumUnit)
42
+ targetNumUnit = nUnit;
43
+ if (!targetDenUnit)
44
+ targetDenUnit = dUnit;
45
+ if (targetDenValue === undefined)
46
+ targetDenValue = (_c = ratio.denominator) === null || _c === void 0 ? void 0 : _c.value;
47
+ const nValue = ratio.numerator.value;
48
+ const dValue = (_e = (_d = ratio.denominator) === null || _d === void 0 ? void 0 : _d.value) !== null && _e !== void 0 ? _e : 1;
49
+ const nFactor = (0, units_1.getBaseUnitFactor)(nUnit);
50
+ const dFactor = (dCat === "volume" || dCat === "mass") ? (0, units_1.getBaseUnitFactor)(dUnit) : 1;
51
+ totalMgPerMlOrG += (nValue * nFactor) / (dValue * dFactor);
52
+ if (dCat === "volume")
53
+ hasVolume = true;
54
+ if (dCat === "mass")
55
+ hasWeightDenominator = true;
56
+ }
57
+ if (totalMgPerMlOrG === 0)
58
+ return null;
59
+ const isComposite = parts.length > 1;
60
+ const resultNumUnit = isComposite ? "mg" : (targetNumUnit || "mg");
61
+ let resultDenUnit = targetDenUnit || "unit";
62
+ if (isComposite) {
63
+ if (hasVolume)
64
+ resultDenUnit = "mL";
65
+ else if (hasWeightDenominator)
66
+ resultDenUnit = "g";
67
+ else
68
+ resultDenUnit = "unit";
69
+ }
70
+ const resultDenValue = isComposite ? 1 : (targetDenValue !== null && targetDenValue !== void 0 ? targetDenValue : 1);
71
+ const denCat = (0, units_1.getUnitCategory)(resultDenUnit);
72
+ const dBaseFactor = (denCat === "volume" || denCat === "mass")
73
+ ? (0, units_1.getBaseUnitFactor)(resultDenUnit)
74
+ : 1;
75
+ const totalBaseDenominatorValue = resultDenValue * dBaseFactor;
76
+ const totalNumeratorMg = totalMgPerMlOrG * totalBaseDenominatorValue;
77
+ const finalNumValue = totalNumeratorMg / (0, units_1.getBaseUnitFactor)(resultNumUnit);
78
+ return {
79
+ numerator: { value: finalNumValue, unit: resultNumUnit },
80
+ denominator: { value: resultDenValue, unit: resultDenUnit }
81
+ };
82
+ }
83
+ function isSolidDosageForm(form) {
84
+ const normalized = form.toLowerCase().trim();
85
+ // 1. Get the default unit for this form
86
+ let unit = maps_1.DEFAULT_UNIT_BY_NORMALIZED_FORM[normalized];
87
+ if (!unit) {
88
+ const mapped = maps_1.KNOWN_DOSAGE_FORMS_TO_DOSE[normalized];
89
+ if (mapped) {
90
+ unit = maps_1.DEFAULT_UNIT_BY_NORMALIZED_FORM[mapped];
91
+ }
92
+ }
93
+ // 2. Identify if it's explicitly a liquid/gas unit
94
+ const liquidUnits = ["ml", "spray", "puff", "drop"];
95
+ if (unit) {
96
+ const u = unit.toLowerCase();
97
+ if ((0, array_1.arrayIncludes)(liquidUnits, u))
98
+ return false;
99
+ // Any other mapped unit (g, tab, cap, etc.) is considered solid for % purpose
100
+ return true;
101
+ }
102
+ // 3. Keyword-based heuristics as fallback
103
+ const solidKeywords = [
104
+ "tablet", "capsule", "patch", "cream", "ointment", "gel", "paste",
105
+ "suppositor", "powder", "lozenge", "patch", "stick", "implant",
106
+ "piece", "granule", "lozenge", "pessary"
107
+ ];
108
+ for (let i = 0; i < solidKeywords.length; i++) {
109
+ if (normalized.indexOf(solidKeywords[i]) !== -1)
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+ function parseSingleStrengthPart(part, context) {
115
+ var _a, _b;
116
+ const p = part.trim();
117
+ // 1. Percentage
118
+ const percentMatch = p.match(/^(\d+(?:\.\d+)?)\s*%$/);
119
+ if (percentMatch) {
120
+ const isSolid = (context === null || context === void 0 ? void 0 : context.dosageForm) && isSolidDosageForm(context.dosageForm);
121
+ return {
122
+ numerator: { value: parseFloat(percentMatch[1]), unit: "g" },
123
+ denominator: { value: 100, unit: isSolid ? "g" : "mL" }
124
+ };
125
+ }
126
+ // 2. Ratio
127
+ const ratioMatch = p.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z0-9%]+)?\s*\/\s*(\d+(?:\.\d+)?)?\s*([a-zA-Z0-9%]+)$/);
128
+ if (ratioMatch) {
129
+ return {
130
+ numerator: {
131
+ value: parseFloat(ratioMatch[1]),
132
+ unit: ((_a = ratioMatch[2]) === null || _a === void 0 ? void 0 : _a.trim()) || "mg"
133
+ },
134
+ denominator: {
135
+ value: ratioMatch[3] ? parseFloat(ratioMatch[3]) : 1,
136
+ unit: (_b = ratioMatch[4]) === null || _b === void 0 ? void 0 : _b.trim()
137
+ }
138
+ };
139
+ }
140
+ // 3. Simple Quantity
141
+ const quantityMatch = p.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z0-9%]+)$/);
142
+ if (quantityMatch) {
143
+ return {
144
+ numerator: { value: parseFloat(quantityMatch[1]), unit: quantityMatch[2].trim() },
145
+ denominator: { value: 1, unit: "unit" }
146
+ };
147
+ }
148
+ return null;
149
+ }
@@ -0,0 +1,14 @@
1
+ export declare const MASS_UNITS: Record<string, number>;
2
+ export declare const VOLUME_UNITS: Record<string, number>;
3
+ export declare function getUnitCategory(unit?: string): "mass" | "volume" | "other";
4
+ export declare function getBaseUnitFactor(unit?: string): number;
5
+ export declare function convertValue(value: number, fromUnit: string, toUnit: string, strength?: {
6
+ numerator: {
7
+ value: number;
8
+ unit: string;
9
+ };
10
+ denominator: {
11
+ value: number;
12
+ unit: string;
13
+ };
14
+ }): number | null;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VOLUME_UNITS = exports.MASS_UNITS = void 0;
4
+ exports.getUnitCategory = getUnitCategory;
5
+ exports.getBaseUnitFactor = getBaseUnitFactor;
6
+ exports.convertValue = convertValue;
7
+ exports.MASS_UNITS = {
8
+ kg: 1000000,
9
+ g: 1000,
10
+ mg: 1,
11
+ mcg: 0.001,
12
+ ug: 0.001,
13
+ microg: 0.001,
14
+ ng: 0.000001
15
+ };
16
+ exports.VOLUME_UNITS = {
17
+ l: 1000,
18
+ dl: 100,
19
+ ml: 1,
20
+ ul: 0.001,
21
+ microl: 0.001,
22
+ cm3: 1,
23
+ tsp: 5,
24
+ tbsp: 15
25
+ };
26
+ function getUnitCategory(unit) {
27
+ if (!unit)
28
+ return "other";
29
+ const u = unit.toLowerCase();
30
+ if (exports.MASS_UNITS[u] !== undefined)
31
+ return "mass";
32
+ if (exports.VOLUME_UNITS[u] !== undefined)
33
+ return "volume";
34
+ return "other";
35
+ }
36
+ function getBaseUnitFactor(unit) {
37
+ var _a, _b;
38
+ if (!unit)
39
+ return 1;
40
+ const u = unit.toLowerCase();
41
+ return (_b = (_a = exports.MASS_UNITS[u]) !== null && _a !== void 0 ? _a : exports.VOLUME_UNITS[u]) !== null && _b !== void 0 ? _b : 1;
42
+ }
43
+ function convertValue(value, fromUnit, toUnit, strength) {
44
+ const f = fromUnit.toLowerCase();
45
+ const t = toUnit.toLowerCase();
46
+ if (f === t)
47
+ return value;
48
+ const fCat = getUnitCategory(f);
49
+ const tCat = getUnitCategory(t);
50
+ // 1. Same category conversion
51
+ if (fCat === tCat && fCat !== "other") {
52
+ const fFactor = getBaseUnitFactor(f);
53
+ const tFactor = getBaseUnitFactor(t);
54
+ return (value * fFactor) / tFactor;
55
+ }
56
+ // 2. Cross-category conversion using strength
57
+ if (strength && ((fCat === "mass" && tCat === "volume") || (fCat === "volume" && tCat === "mass"))) {
58
+ const numUnit = strength.numerator.unit.toLowerCase();
59
+ const denUnit = strength.denominator.unit.toLowerCase();
60
+ const numCat = getUnitCategory(numUnit);
61
+ const denCat = getUnitCategory(denUnit);
62
+ if (numCat !== denCat && numCat !== "other" && denCat !== "other") {
63
+ const massSide = numCat === "mass" ? strength.numerator : strength.denominator;
64
+ const volSide = numCat === "volume" ? strength.numerator : strength.denominator;
65
+ // Normalize bridge to base units (mg/mL)
66
+ const bridgeDensity = (massSide.value * getBaseUnitFactor(massSide.unit)) / (volSide.value * getBaseUnitFactor(volSide.unit));
67
+ if (fCat === "mass") {
68
+ // Mass to Volume: value_mg / density_mg_per_ml
69
+ const valueMg = value * getBaseUnitFactor(fromUnit);
70
+ const valueMl = valueMg / bridgeDensity;
71
+ return valueMl / getBaseUnitFactor(toUnit);
72
+ }
73
+ else {
74
+ // Volume to Mass: value_ml * density_mg_per_ml
75
+ const valueMl = value * getBaseUnitFactor(fromUnit);
76
+ const valueMg = valueMl * bridgeDensity;
77
+ return valueMg / getBaseUnitFactor(toUnit);
78
+ }
79
+ }
80
+ }
81
+ return null;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ezmedicationinput",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Parse concise medication sigs into FHIR R5 Dosage JSON",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,2 +0,0 @@
1
- export declare function enumEntries<T extends Record<string, string | number>>(enumeration: T): Array<[keyof T, T[keyof T]]>;
2
- export declare function enumValues<T extends Record<string, string | number>>(enumeration: T): Array<T[keyof T]>;
@@ -1,7 +0,0 @@
1
- import { objectEntries, objectValues } from "./object";
2
- export function enumEntries(enumeration) {
3
- return objectEntries(enumeration);
4
- }
5
- export function enumValues(enumeration) {
6
- return objectValues(enumeration);
7
- }