ezmedicationinput 0.1.38 → 0.1.40

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
@@ -184,6 +184,8 @@ Highlights:
184
184
  - Supports multiple timing tokens in sequence (e.g. `1 tab po morn hs`).
185
185
  - Surfaces PRN reasons from built-ins or custom `prnReasons` entries while
186
186
  preserving numeric doses pulled from the typed prefix.
187
+ - When `enableMealDashSyntax` is enabled, suggests dash-based meal patterns
188
+ (e.g. `1-0-1`, `1-0-0-1 ac`) only when dash syntax is being typed.
187
189
 
188
190
  ## Dictionaries
189
191
 
package/dist/index.js CHANGED
@@ -58,6 +58,15 @@ Object.defineProperty(exports, "DEFAULT_BODY_SITE_SNOMED_SOURCE", { enumerable:
58
58
  Object.defineProperty(exports, "DEFAULT_ROUTE_SYNONYMS", { enumerable: true, get: function () { return maps_1.DEFAULT_ROUTE_SYNONYMS; } });
59
59
  Object.defineProperty(exports, "DEFAULT_UNIT_BY_ROUTE", { enumerable: true, get: function () { return maps_1.DEFAULT_UNIT_BY_ROUTE; } });
60
60
  Object.defineProperty(exports, "KNOWN_DOSAGE_FORMS_TO_DOSE", { enumerable: true, get: function () { return maps_1.KNOWN_DOSAGE_FORMS_TO_DOSE; } });
61
+ const REPEAT_NON_ANCHOR_KEYS = [
62
+ "count",
63
+ "frequency",
64
+ "frequencyMax",
65
+ "period",
66
+ "periodMax",
67
+ "periodUnit",
68
+ "offset"
69
+ ];
61
70
  function parseMealDashValues(token) {
62
71
  if (!/^[0-9]+(?:\.[0-9]+)?(?:-[0-9]+(?:\.[0-9]+)?){2,3}$/.test(token)) {
63
72
  return undefined;
@@ -155,6 +164,189 @@ function toSegmentMeta(segments) {
155
164
  range: { start: segment.start, end: segment.end }
156
165
  }));
157
166
  }
167
+ /**
168
+ * Deep equality helper for plain JSON-like parser output objects.
169
+ *
170
+ * @param left Left-side value.
171
+ * @param right Right-side value.
172
+ * @returns `true` when both values are structurally equal.
173
+ */
174
+ function deepEqual(left, right) {
175
+ if (left === right) {
176
+ return true;
177
+ }
178
+ if (left === null || right === null) {
179
+ return left === right;
180
+ }
181
+ if (Array.isArray(left) || Array.isArray(right)) {
182
+ if (!Array.isArray(left) || !Array.isArray(right)) {
183
+ return false;
184
+ }
185
+ if (left.length !== right.length) {
186
+ return false;
187
+ }
188
+ for (let i = 0; i < left.length; i += 1) {
189
+ if (!deepEqual(left[i], right[i])) {
190
+ return false;
191
+ }
192
+ }
193
+ return true;
194
+ }
195
+ if (typeof left !== "object" || typeof right !== "object") {
196
+ return false;
197
+ }
198
+ const leftRecord = left;
199
+ const rightRecord = right;
200
+ const leftKeys = Object.keys(leftRecord).filter((key) => leftRecord[key] !== undefined);
201
+ const rightKeys = Object.keys(rightRecord).filter((key) => rightRecord[key] !== undefined);
202
+ if (leftKeys.length !== rightKeys.length) {
203
+ return false;
204
+ }
205
+ for (const key of leftKeys) {
206
+ if (!Object.prototype.hasOwnProperty.call(rightRecord, key)) {
207
+ return false;
208
+ }
209
+ if (!deepEqual(leftRecord[key], rightRecord[key])) {
210
+ return false;
211
+ }
212
+ }
213
+ return true;
214
+ }
215
+ /**
216
+ * Compares two string arrays as sets.
217
+ *
218
+ * @param left Left array.
219
+ * @param right Right array.
220
+ * @returns `true` when both arrays contain the same unique values.
221
+ */
222
+ function sameStringSet(left, right) {
223
+ const a = left !== null && left !== void 0 ? left : [];
224
+ const b = right !== null && right !== void 0 ? right : [];
225
+ if (a.length !== b.length) {
226
+ return false;
227
+ }
228
+ const set = new Set(a);
229
+ if (set.size !== b.length) {
230
+ return false;
231
+ }
232
+ for (const value of b) {
233
+ if (!set.has(value)) {
234
+ return false;
235
+ }
236
+ }
237
+ return true;
238
+ }
239
+ /**
240
+ * Determines whether a repeat block only uses merge-safe anchor fields.
241
+ *
242
+ * @param repeat FHIR timing repeat payload.
243
+ * @returns `true` when repeat contains only `when`/`timeOfDay`/`dayOfWeek`.
244
+ */
245
+ function isMergeableAnchorRepeat(repeat) {
246
+ if (!repeat) {
247
+ return true;
248
+ }
249
+ for (const key of REPEAT_NON_ANCHOR_KEYS) {
250
+ if (repeat[key] !== undefined) {
251
+ return false;
252
+ }
253
+ }
254
+ return true;
255
+ }
256
+ /**
257
+ * Checks whether two parsed items can be merged without changing semantics.
258
+ *
259
+ * @param base Existing merged item candidate.
260
+ * @param next Incoming parsed item.
261
+ * @returns `true` when both items differ only by merge-safe timing anchors.
262
+ */
263
+ function canMergeTimingOnly(base, next) {
264
+ const baseTiming = base.fhir.timing;
265
+ const nextTiming = next.fhir.timing;
266
+ const baseRepeat = baseTiming === null || baseTiming === void 0 ? void 0 : baseTiming.repeat;
267
+ const nextRepeat = nextTiming === null || nextTiming === void 0 ? void 0 : nextTiming.repeat;
268
+ if (!baseRepeat || !nextRepeat) {
269
+ return false;
270
+ }
271
+ if (!isMergeableAnchorRepeat(baseRepeat) || !isMergeableAnchorRepeat(nextRepeat)) {
272
+ return false;
273
+ }
274
+ if (!sameStringSet(baseRepeat.dayOfWeek, nextRepeat.dayOfWeek)) {
275
+ return false;
276
+ }
277
+ if (!deepEqual(baseTiming === null || baseTiming === void 0 ? void 0 : baseTiming.code, nextTiming === null || nextTiming === void 0 ? void 0 : nextTiming.code)) {
278
+ return false;
279
+ }
280
+ if (!deepEqual(baseTiming === null || baseTiming === void 0 ? void 0 : baseTiming.event, nextTiming === null || nextTiming === void 0 ? void 0 : nextTiming.event)) {
281
+ return false;
282
+ }
283
+ return (deepEqual(base.fhir.doseAndRate, next.fhir.doseAndRate) &&
284
+ deepEqual(base.fhir.route, next.fhir.route) &&
285
+ deepEqual(base.fhir.site, next.fhir.site) &&
286
+ deepEqual(base.fhir.additionalInstruction, next.fhir.additionalInstruction) &&
287
+ deepEqual(base.fhir.asNeededBoolean, next.fhir.asNeededBoolean) &&
288
+ deepEqual(base.fhir.asNeededFor, next.fhir.asNeededFor));
289
+ }
290
+ /**
291
+ * Returns a stable unique list preserving first-seen order.
292
+ *
293
+ * @param values Input values.
294
+ * @returns Deduplicated values in insertion order.
295
+ */
296
+ function uniqueStrings(values) {
297
+ const seen = new Set();
298
+ const output = [];
299
+ for (const value of values) {
300
+ if (!seen.has(value)) {
301
+ seen.add(value);
302
+ output.push(value);
303
+ }
304
+ }
305
+ return output;
306
+ }
307
+ /**
308
+ * Merges two parse results that are known to be timing-compatible.
309
+ *
310
+ * @param base Existing merged result.
311
+ * @param next Next result to fold into `base`.
312
+ * @param options Parse options used to render localized text.
313
+ * @returns New merged parse result.
314
+ */
315
+ function mergeParseResults(base, next, options) {
316
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
317
+ const baseRepeat = (_b = (_a = base.fhir.timing) === null || _a === void 0 ? void 0 : _a.repeat) !== null && _b !== void 0 ? _b : {};
318
+ const nextRepeat = (_d = (_c = next.fhir.timing) === null || _c === void 0 ? void 0 : _c.repeat) !== null && _d !== void 0 ? _d : {};
319
+ const mergedWhen = uniqueStrings([...((_e = baseRepeat.when) !== null && _e !== void 0 ? _e : []), ...((_f = nextRepeat.when) !== null && _f !== void 0 ? _f : [])]);
320
+ const mergedTimeOfDay = uniqueStrings([...((_g = baseRepeat.timeOfDay) !== null && _g !== void 0 ? _g : []), ...((_h = nextRepeat.timeOfDay) !== null && _h !== void 0 ? _h : [])]).sort();
321
+ const mergedRepeat = Object.assign(Object.assign(Object.assign(Object.assign({}, baseRepeat), (nextRepeat.dayOfWeek ? { dayOfWeek: nextRepeat.dayOfWeek } : {})), (mergedWhen.length ? { when: mergedWhen } : {})), (mergedTimeOfDay.length ? { timeOfDay: mergedTimeOfDay } : {}));
322
+ const mergedFhir = Object.assign(Object.assign({}, base.fhir), { timing: Object.assign(Object.assign({}, ((_j = base.fhir.timing) !== null && _j !== void 0 ? _j : {})), { repeat: mergedRepeat }) });
323
+ const shortText = formatSig(mergedFhir, "short", options);
324
+ const longText = formatSig(mergedFhir, "long", options);
325
+ mergedFhir.text = longText;
326
+ return {
327
+ fhir: mergedFhir,
328
+ shortText,
329
+ longText,
330
+ warnings: uniqueStrings([...((_k = base.warnings) !== null && _k !== void 0 ? _k : []), ...((_l = next.warnings) !== null && _l !== void 0 ? _l : [])]),
331
+ meta: Object.assign(Object.assign({}, base.meta), { consumedTokens: uniqueStrings([...((_m = base.meta.consumedTokens) !== null && _m !== void 0 ? _m : []), ...((_o = next.meta.consumedTokens) !== null && _o !== void 0 ? _o : [])]), leftoverText: uniqueStrings([base.meta.leftoverText, next.meta.leftoverText].filter((value) => !!value)).join(" ").trim() || undefined, siteLookups: [...((_p = base.meta.siteLookups) !== null && _p !== void 0 ? _p : []), ...((_q = next.meta.siteLookups) !== null && _q !== void 0 ? _q : [])], prnReasonLookups: [...((_r = base.meta.prnReasonLookups) !== null && _r !== void 0 ? _r : []), ...((_s = next.meta.prnReasonLookups) !== null && _s !== void 0 ? _s : [])] })
332
+ };
333
+ }
334
+ /**
335
+ * Appends a parsed segment result to the batch, reusing the current item when
336
+ * timing-only expansion can be represented as a single dosage element.
337
+ *
338
+ * @param items Accumulated batch items.
339
+ * @param next Newly parsed segment result.
340
+ * @param options Parse options used to format merged text.
341
+ */
342
+ function appendParseResult(items, next, options) {
343
+ const previous = items[items.length - 1];
344
+ if (previous && canMergeTimingOnly(previous, next)) {
345
+ items[items.length - 1] = mergeParseResults(previous, next, options);
346
+ return;
347
+ }
348
+ items.push(next);
349
+ }
158
350
  function parseSig(input, options) {
159
351
  const segments = expandMealDashSegments((0, segment_1.splitSigSegments)(input), options);
160
352
  const carry = {};
@@ -166,7 +358,7 @@ function parseSig(input, options) {
166
358
  (0, parser_1.applySiteCoding)(internal, options);
167
359
  const result = buildParseResult(internal, options);
168
360
  rebaseParseResult(result, input, segment.start);
169
- results.push(result);
361
+ appendParseResult(results, result, options);
170
362
  updateCarryForward(carry, internal);
171
363
  }
172
364
  const legacy = resolveLegacyParseResult(results, input, options);
@@ -232,7 +424,7 @@ function parseSigAsync(input, options) {
232
424
  yield (0, parser_1.applySiteCodingAsync)(internal, options);
233
425
  const result = buildParseResult(internal, options);
234
426
  rebaseParseResult(result, input, segment.start);
235
- results.push(result);
427
+ appendParseResult(results, result, options);
236
428
  updateCarryForward(carry, internal);
237
429
  }
238
430
  const legacy = resolveLegacyParseResult(results, input, options);
package/dist/maps.js CHANGED
@@ -668,6 +668,7 @@ exports.EVENT_TIMING_TOKENS = {
668
668
  pcd: types_1.EventTiming["After Lunch"],
669
669
  pcv: types_1.EventTiming["After Dinner"],
670
670
  wm: types_1.EventTiming.Meal,
671
+ c: types_1.EventTiming.Meal,
671
672
  "with meals": types_1.EventTiming.Meal,
672
673
  "with meal": types_1.EventTiming.Meal,
673
674
  "with food": types_1.EventTiming.Meal,
package/dist/parser.js CHANGED
@@ -780,26 +780,84 @@ function parseTimeToFhir(timeStr) {
780
780
  const m = minute < 10 ? `0${minute}` : `${minute}`;
781
781
  return `${h}:${m}:00`;
782
782
  }
783
+ function extractAttachedAtTimeToken(lower) {
784
+ if (lower.length <= 1) {
785
+ return undefined;
786
+ }
787
+ if (lower.charAt(0) === "@") {
788
+ const candidate = lower.slice(1);
789
+ return parseTimeToFhir(candidate) ? candidate : undefined;
790
+ }
791
+ if (lower.startsWith("at") && lower.length > 2 && /^\d/.test(lower.charAt(2))) {
792
+ const candidate = lower.slice(2);
793
+ return parseTimeToFhir(candidate) ? candidate : undefined;
794
+ }
795
+ return undefined;
796
+ }
797
+ function isAtPrefixToken(lower) {
798
+ return lower === "@" || lower === "at" || extractAttachedAtTimeToken(lower) !== undefined;
799
+ }
783
800
  function tryParseTimeBasedSchedule(internal, tokens, index) {
784
801
  const token = tokens[index];
785
802
  if (internal.consumed.has(token.index))
786
803
  return false;
787
- let isAtPrefix = token.lower === "@" || token.lower === "at";
804
+ const attachedAtTime = extractAttachedAtTimeToken(token.lower);
805
+ const isAtPrefix = isAtPrefixToken(token.lower);
788
806
  if (!isAtPrefix && !/^\d/.test(token.lower))
789
807
  return false;
790
808
  let nextIndex = index;
791
- if (isAtPrefix)
792
- nextIndex++;
793
809
  const times = [];
794
810
  const consumedIndices = [];
795
811
  const timeTokens = [];
796
- if (isAtPrefix) {
812
+ if (token.lower === "@" || token.lower === "at") {
813
+ consumedIndices.push(index);
814
+ nextIndex++;
815
+ }
816
+ else if (attachedAtTime) {
817
+ let timeStr = attachedAtTime;
818
+ const lookaheadIndices = [];
819
+ if (!timeStr.includes("am") && !timeStr.includes("pm")) {
820
+ const ampmToken = tokens[index + 1];
821
+ if (ampmToken &&
822
+ !internal.consumed.has(ampmToken.index) &&
823
+ (ampmToken.lower === "am" || ampmToken.lower === "pm")) {
824
+ timeStr += ampmToken.lower;
825
+ lookaheadIndices.push(index + 1);
826
+ }
827
+ }
828
+ const compactTime = parseTimeToFhir(timeStr);
829
+ if (!compactTime) {
830
+ return false;
831
+ }
832
+ times.push(compactTime);
833
+ timeTokens.push(timeStr);
797
834
  consumedIndices.push(index);
835
+ for (const idx of lookaheadIndices) {
836
+ consumedIndices.push(idx);
837
+ }
838
+ nextIndex = index + 1 + lookaheadIndices.length;
798
839
  }
799
840
  while (nextIndex < tokens.length) {
800
841
  const nextToken = tokens[nextIndex];
801
842
  if (!nextToken || internal.consumed.has(nextToken.index))
802
843
  break;
844
+ if ((nextToken.lower === "," || nextToken.lower === "and") && times.length > 0) {
845
+ const peekToken = tokens[nextIndex + 1];
846
+ if (peekToken && !internal.consumed.has(peekToken.index)) {
847
+ let peekStr = peekToken.lower;
848
+ const ampmToken = tokens[nextIndex + 2];
849
+ if (ampmToken &&
850
+ !internal.consumed.has(ampmToken.index) &&
851
+ (ampmToken.lower === "am" || ampmToken.lower === "pm")) {
852
+ peekStr += ampmToken.lower;
853
+ }
854
+ if (parseTimeToFhir(peekStr)) {
855
+ consumedIndices.push(nextIndex);
856
+ nextIndex++;
857
+ continue;
858
+ }
859
+ }
860
+ }
803
861
  let timeStr = nextToken.lower;
804
862
  let lookaheadIndices = [];
805
863
  // Look ahead for am/pm if current token is just a number or doesn't have am/pm
@@ -1066,6 +1124,11 @@ function splitToken(token) {
1066
1124
  if (/^[0-9]+(?:\.[0-9]+)?$/.test(token)) {
1067
1125
  return [token];
1068
1126
  }
1127
+ const compactPoMeal = token.match(/^(po)(ac|pc|c)$/i);
1128
+ if (compactPoMeal) {
1129
+ const [, po, meal] = compactPoMeal;
1130
+ return [po, meal];
1131
+ }
1069
1132
  if (/^[A-Za-z]+$/.test(token)) {
1070
1133
  return [token];
1071
1134
  }
@@ -1077,6 +1140,11 @@ function splitToken(token) {
1077
1140
  const match = token.match(/^([0-9]+(?:\.[0-9]+)?)([A-Za-z]+)$/);
1078
1141
  if (match) {
1079
1142
  const [, num, unit] = match;
1143
+ const compactPoMealUnit = unit.match(/^(po)(ac|pc|c)$/i);
1144
+ if (compactPoMealUnit) {
1145
+ const [, po, meal] = compactPoMealUnit;
1146
+ return [num, po, meal];
1147
+ }
1080
1148
  if (!/^x\d+/i.test(unit) && !/^q\d+/i.test(unit)) {
1081
1149
  return [num, unit];
1082
1150
  }
@@ -1514,7 +1582,7 @@ function isTimingAnchorOrPrefix(tokens, index, prnReasonStart) {
1514
1582
  maps_1.TIMING_ABBREVIATIONS[lower] ||
1515
1583
  (comboKey && COMBO_EVENT_TIMINGS[comboKey]) ||
1516
1584
  (lower === "pc" || lower === "ac" || lower === "after" || lower === "before") ||
1517
- (lower === "at" || lower === "@" || lower === "on" || lower === "with") ||
1585
+ (isAtPrefixToken(lower) || lower === "on" || lower === "with") ||
1518
1586
  /^\d/.test(lower));
1519
1587
  }
1520
1588
  function parseAnchorSequence(internal, tokens, index, prefixCode) {
@@ -1963,11 +2031,11 @@ function parseInternal(input, options) {
1963
2031
  : types_1.EventTiming["Before Meal"]);
1964
2032
  continue;
1965
2033
  }
1966
- if (token.lower === "at" || token.lower === "@" || token.lower === "on" || token.lower === "with") {
1967
- if (parseAnchorSequence(internal, tokens, i)) {
2034
+ if (isAtPrefixToken(token.lower) || token.lower === "on" || token.lower === "with") {
2035
+ if (tryParseTimeBasedSchedule(internal, tokens, i)) {
1968
2036
  continue;
1969
2037
  }
1970
- if (tryParseTimeBasedSchedule(internal, tokens, i)) {
2038
+ if (parseAnchorSequence(internal, tokens, i)) {
1971
2039
  continue;
1972
2040
  }
1973
2041
  // If none of the above consume it, and it's a known anchor prefix, mark it
package/dist/schedule.js CHANGED
@@ -280,6 +280,44 @@ function getLocalWeekday(date, timeZone) {
280
280
  throw new Error(`Unexpected weekday token: ${formatted}`);
281
281
  }
282
282
  }
283
+ function getLocalDayNumber(date, timeZone) {
284
+ const { year, month, day } = getTimeParts(date, timeZone);
285
+ return Math.floor(Date.UTC(year, month - 1, day) / (24 * 60 * 60 * 1000));
286
+ }
287
+ function getLocalMonthIndex(date, timeZone) {
288
+ const { year, month } = getTimeParts(date, timeZone);
289
+ return year * 12 + (month - 1);
290
+ }
291
+ function isDateAlignedToPeriodCycle(candidateDay, anchorDay, repeat, timeZone) {
292
+ const period = repeat.period;
293
+ const periodUnit = repeat.periodUnit;
294
+ if (!period || period <= 0 || !periodUnit) {
295
+ return true;
296
+ }
297
+ if (periodUnit === "d") {
298
+ const deltaDays = getLocalDayNumber(candidateDay, timeZone) - getLocalDayNumber(anchorDay, timeZone);
299
+ return deltaDays >= 0 && deltaDays % period === 0;
300
+ }
301
+ if (periodUnit === "wk") {
302
+ const deltaDays = getLocalDayNumber(candidateDay, timeZone) - getLocalDayNumber(anchorDay, timeZone);
303
+ if (deltaDays < 0) {
304
+ return false;
305
+ }
306
+ const deltaWeeks = Math.floor(deltaDays / 7);
307
+ return deltaWeeks % period === 0;
308
+ }
309
+ if (periodUnit === "mo") {
310
+ const deltaMonths = getLocalMonthIndex(candidateDay, timeZone) - getLocalMonthIndex(anchorDay, timeZone);
311
+ return deltaMonths >= 0 && deltaMonths % period === 0;
312
+ }
313
+ if (periodUnit === "a") {
314
+ const candidateYear = getTimeParts(candidateDay, timeZone).year;
315
+ const anchorYear = getTimeParts(anchorDay, timeZone).year;
316
+ const deltaYears = candidateYear - anchorYear;
317
+ return deltaYears >= 0 && deltaYears % period === 0;
318
+ }
319
+ return true;
320
+ }
283
321
  /** Parses arbitrary string/Date inputs into a valid Date instance. */
284
322
  function coerceDate(value, label) {
285
323
  const date = value instanceof Date ? value : new Date(value);
@@ -706,12 +744,29 @@ function nextDueDoses(dosage, options) {
706
744
  (!repeat.frequency ||
707
745
  repeat.periodUnit !== "d" ||
708
746
  (repeat.frequency === 1 && repeat.period > 1));
709
- if (treatAsInterval) {
747
+ const supportsDayFilteredInterval = isDayFilteredIntervalSupported(repeat, enforceDayFilter);
748
+ if (treatAsInterval && supportsDayFilteredInterval) {
710
749
  // True interval schedules advance from the order start in fixed units. The
711
750
  // timing.code remains advisory so we only rely on the period/unit fields.
712
751
  const candidates = generateIntervalSeries(baseTime, from, effectiveLimit, repeat, timeZone, dayFilter, enforceDayFilter, orderedAt);
713
752
  return candidates;
714
753
  }
754
+ if (enforceDayFilter &&
755
+ repeat.period &&
756
+ repeat.periodUnit &&
757
+ (repeat.periodUnit === "wk" || repeat.periodUnit === "mo" || repeat.periodUnit === "a")) {
758
+ return generateDayFilteredPeriodSeries({
759
+ repeat,
760
+ timeZone,
761
+ dayFilter,
762
+ anchorDay: startOfLocalDay(baseTime, timeZone),
763
+ startDay: from,
764
+ from,
765
+ orderedAt,
766
+ limit: effectiveLimit,
767
+ defaultClock: toLocalClock(baseTime, timeZone)
768
+ }).slice(0, effectiveLimit);
769
+ }
715
770
  if (repeat.frequency && repeat.period && repeat.periodUnit) {
716
771
  // Pure frequency schedules (e.g., BID/TID) rely on institution clocks that
717
772
  // clinicians expect. These can be overridden via configuration when
@@ -851,7 +906,8 @@ function derivePriorCountFromHistory(timing, repeat, config, orderedAt, from, ti
851
906
  (!repeat.frequency ||
852
907
  repeat.periodUnit !== "d" ||
853
908
  (repeat.frequency === 1 && repeat.period > 1));
854
- if (treatAsInterval) {
909
+ const supportsDayFilteredInterval = isDayFilteredIntervalSupported(repeat, enforceDayFilter);
910
+ if (treatAsInterval && supportsDayFilteredInterval) {
855
911
  const increment = createIntervalStepper(repeat, timeZone);
856
912
  if (!increment) {
857
913
  return count;
@@ -875,6 +931,24 @@ function derivePriorCountFromHistory(timing, repeat, config, orderedAt, from, ti
875
931
  }
876
932
  return count;
877
933
  }
934
+ if (enforceDayFilter &&
935
+ repeat.period &&
936
+ repeat.periodUnit &&
937
+ (repeat.periodUnit === "wk" || repeat.periodUnit === "mo" || repeat.periodUnit === "a")) {
938
+ const generated = generateDayFilteredPeriodSeries({
939
+ repeat,
940
+ timeZone,
941
+ dayFilter,
942
+ anchorDay: startOfLocalDay(orderedAt, timeZone),
943
+ startDay: orderedAt,
944
+ from: orderedAt,
945
+ to: from,
946
+ orderedAt,
947
+ limit: normalizedCount !== null && normalizedCount !== void 0 ? normalizedCount : 31 * 365,
948
+ defaultClock: toLocalClock(orderedAt, timeZone)
949
+ });
950
+ return count + generated.length;
951
+ }
878
952
  if (repeat.frequency && repeat.period && repeat.periodUnit) {
879
953
  const clocks = resolveFrequencyClocks(timing, config);
880
954
  if (clocks.length === 0) {
@@ -966,6 +1040,10 @@ function generateIntervalSeries(baseTime, from, effectiveLimit, repeat, timeZone
966
1040
  }
967
1041
  /**
968
1042
  * Builds a function that advances a Date according to repeat.period/unit.
1043
+ *
1044
+ * @param repeat FHIR repeat object containing `period` and `periodUnit`.
1045
+ * @param timeZone IANA timezone used for calendar-aware month/year stepping.
1046
+ * @returns Stepper function advancing one interval, or `null` when unsupported.
969
1047
  */
970
1048
  function createIntervalStepper(repeat, timeZone) {
971
1049
  const { period, periodUnit } = repeat;
@@ -993,7 +1071,14 @@ function createIntervalStepper(repeat, timeZone) {
993
1071
  }
994
1072
  return null;
995
1073
  }
996
- /** Adds calendar months while respecting varying month lengths and DST. */
1074
+ /**
1075
+ * Adds calendar months while respecting varying month lengths and DST.
1076
+ *
1077
+ * @param date Starting instant.
1078
+ * @param months Number of calendar months to add.
1079
+ * @param timeZone IANA timezone used for wall-clock preservation.
1080
+ * @returns Shifted date preserving local clock and clamped day-of-month.
1081
+ */
997
1082
  function addCalendarMonths(date, months, timeZone) {
998
1083
  const { year, month, day, hour, minute, second } = getTimeParts(date, timeZone);
999
1084
  const targetMonthIndex = month - 1 + months;
@@ -1014,8 +1099,114 @@ function addCalendarMonths(date, months, timeZone) {
1014
1099
  }
1015
1100
  return final;
1016
1101
  }
1102
+ /**
1103
+ * Determines whether interval stepping can safely combine with a day-of-week filter.
1104
+ *
1105
+ * @param repeat FHIR timing repeat object containing period unit metadata.
1106
+ * @param enforceDayFilter Whether a `dayOfWeek` filter is active for this schedule.
1107
+ * @returns `true` when interval stepping should be used directly; otherwise fallback logic is required.
1108
+ */
1109
+ function isDayFilteredIntervalSupported(repeat, enforceDayFilter) {
1110
+ if (!enforceDayFilter) {
1111
+ return true;
1112
+ }
1113
+ return (repeat.periodUnit === "s" ||
1114
+ repeat.periodUnit === "min" ||
1115
+ repeat.periodUnit === "h" ||
1116
+ repeat.periodUnit === "d");
1117
+ }
1118
+ /**
1119
+ * Expands weekly/monthly/yearly day-filtered schedules into concrete dose timestamps.
1120
+ *
1121
+ * @param options Configuration describing bounds, cadence, and clock defaults.
1122
+ * @param options.repeat FHIR repeat block driving cadence and cycle alignment.
1123
+ * @param options.timeZone IANA timezone used for local weekday and clock resolution.
1124
+ * @param options.dayFilter Set of lowercased weekdays that are allowed (e.g. `mon`, `tue`).
1125
+ * @param options.anchorDay Cycle anchor day used to determine period alignment.
1126
+ * @param options.startDay First local day to begin scanning.
1127
+ * @param options.from Inclusive lower bound for candidate timestamps.
1128
+ * @param options.to Optional exclusive upper bound for candidate timestamps.
1129
+ * @param options.orderedAt Optional lower bound representing the order start.
1130
+ * @param options.limit Maximum number of timestamps to emit.
1131
+ * @param options.defaultClock Default local clock (`HH:MM:SS`) when no explicit clock is provided.
1132
+ * @returns Sorted zoned ISO timestamps matching the requested cadence and bounds.
1133
+ */
1134
+ function generateDayFilteredPeriodSeries(options) {
1135
+ const { repeat, timeZone, dayFilter, anchorDay, startDay, from, to, orderedAt, limit, defaultClock } = options;
1136
+ if (limit <= 0) {
1137
+ return [];
1138
+ }
1139
+ const results = [];
1140
+ const seen = new Set();
1141
+ const clocks = [defaultClock !== null && defaultClock !== void 0 ? defaultClock : "08:00:00"];
1142
+ let currentDay = startOfLocalDay(startDay, timeZone);
1143
+ let iterations = 0;
1144
+ const startMs = currentDay.getTime();
1145
+ const estimatedDays = to
1146
+ ? Math.max(1, Math.ceil((to.getTime() - startMs) / (24 * 60 * 60 * 1000)))
1147
+ : limit * 31;
1148
+ const maxIterations = Math.max(limit * 31, estimatedDays + 31);
1149
+ while (results.length < limit &&
1150
+ iterations < maxIterations &&
1151
+ (!to || currentDay < to)) {
1152
+ const weekday = getLocalWeekday(currentDay, timeZone);
1153
+ const inPeriodCycle = isDateAlignedToPeriodCycle(currentDay, anchorDay, repeat, timeZone);
1154
+ if (inPeriodCycle && dayFilter.has(weekday)) {
1155
+ for (const clock of clocks) {
1156
+ const zoned = makeZonedDateFromDay(currentDay, timeZone, clock);
1157
+ if (!zoned) {
1158
+ continue;
1159
+ }
1160
+ if (zoned < from) {
1161
+ continue;
1162
+ }
1163
+ if (to && zoned >= to) {
1164
+ continue;
1165
+ }
1166
+ if (orderedAt && zoned < orderedAt) {
1167
+ continue;
1168
+ }
1169
+ const iso = formatZonedIso(zoned, timeZone);
1170
+ if (!seen.has(iso)) {
1171
+ seen.add(iso);
1172
+ results.push(iso);
1173
+ if (results.length === limit) {
1174
+ break;
1175
+ }
1176
+ }
1177
+ }
1178
+ }
1179
+ currentDay = addLocalDays(currentDay, 1, timeZone);
1180
+ iterations += 1;
1181
+ }
1182
+ return results;
1183
+ }
1184
+ /**
1185
+ * Formats a date into a local `HH:MM:SS` clock for the supplied timezone.
1186
+ *
1187
+ * @param date Source instant.
1188
+ * @param timeZone IANA timezone to interpret the instant.
1189
+ * @returns Local wall-clock time in `HH:MM:SS` format.
1190
+ */
1191
+ function toLocalClock(date, timeZone) {
1192
+ const parts = getTimeParts(date, timeZone);
1193
+ const twoDigits = (value) => (value < 10 ? `0${value}` : `${value}`);
1194
+ const h = twoDigits(parts.hour);
1195
+ const m = twoDigits(parts.minute);
1196
+ const s = twoDigits(parts.second);
1197
+ return `${h}:${m}:${s}`;
1198
+ }
1017
1199
  /**
1018
1200
  * Internal helper to count dose events within a time range.
1201
+ *
1202
+ * @param dosage Dosage definition with timing metadata.
1203
+ * @param from Inclusive lower time bound for counting.
1204
+ * @param to Exclusive upper time bound for counting.
1205
+ * @param config Scheduling configuration (timezone, clocks, offsets).
1206
+ * @param baseTime Anchor instant used for interval alignment.
1207
+ * @param orderedAt Optional order timestamp used as an additional lower bound.
1208
+ * @param limit Optional hard cap on emitted candidates to avoid runaway loops.
1209
+ * @returns Number of unique dose events in the requested window.
1019
1210
  */
1020
1211
  function countScheduleEvents(dosage, from, to, config, baseTime, orderedAt, limit) {
1021
1212
  var _a, _b, _c;
@@ -1098,9 +1289,9 @@ function countScheduleEvents(dosage, from, to, config, baseTime, orderedAt, limi
1098
1289
  !!repeat.periodUnit &&
1099
1290
  (!repeat.frequency ||
1100
1291
  repeat.periodUnit !== "d" ||
1101
- (repeat.frequency === 1 && repeat.period > 1)) &&
1102
- !enforceDayFilter;
1103
- if (treatAsInterval) {
1292
+ (repeat.frequency === 1 && repeat.period > 1));
1293
+ const supportsDayFilteredInterval = isDayFilteredIntervalSupported(repeat, enforceDayFilter);
1294
+ if (treatAsInterval && supportsDayFilteredInterval) {
1104
1295
  const increment = createIntervalStepper(repeat, timeZone);
1105
1296
  if (!increment)
1106
1297
  return count;
@@ -1149,24 +1340,23 @@ function countScheduleEvents(dosage, from, to, config, baseTime, orderedAt, limi
1149
1340
  }
1150
1341
  }
1151
1342
  // Fallback for dayOfWeek with period/periodUnit but no explicit frequency/clocks
1152
- if (enforceDayFilter && repeat.period && repeat.periodUnit) {
1153
- const clocks = ["08:00:00"]; // Default to morning if no times specified
1154
- let currentDayIter = startOfLocalDay(from, timeZone);
1155
- let iter = 0;
1156
- const maxIter = limit !== undefined ? limit * 31 : 365 * 31;
1157
- while (count < (limit !== null && limit !== void 0 ? limit : Infinity) && currentDayIter < to && iter < maxIter) {
1158
- const weekday = getLocalWeekday(currentDayIter, timeZone);
1159
- if (dayFilter.has(weekday)) {
1160
- for (const clock of clocks) {
1161
- const zoned = makeZonedDateFromDay(currentDayIter, timeZone, clock);
1162
- if (zoned)
1163
- recordCandidate(zoned);
1164
- }
1165
- }
1166
- currentDayIter = addLocalDays(currentDayIter, 1, timeZone);
1167
- iter += 1;
1168
- }
1169
- return count;
1343
+ if (enforceDayFilter &&
1344
+ repeat.period &&
1345
+ repeat.periodUnit &&
1346
+ (repeat.periodUnit === "wk" || repeat.periodUnit === "mo" || repeat.periodUnit === "a")) {
1347
+ const generated = generateDayFilteredPeriodSeries({
1348
+ repeat,
1349
+ timeZone,
1350
+ dayFilter,
1351
+ anchorDay: startOfLocalDay(baseTime, timeZone),
1352
+ startDay: from,
1353
+ from,
1354
+ to,
1355
+ orderedAt,
1356
+ limit: limit !== null && limit !== void 0 ? limit : 365 * 31,
1357
+ defaultClock: toLocalClock(baseTime, timeZone)
1358
+ });
1359
+ return count + generated.length;
1170
1360
  }
1171
1361
  return count;
1172
1362
  }
package/dist/suggest.js CHANGED
@@ -729,6 +729,131 @@ function matchesPrefix(_candidate, candidateLower, context) {
729
729
  }
730
730
  return false;
731
731
  }
732
+ function collectMatchedCandidates(candidates, limit, matcher) {
733
+ const suggestions = [];
734
+ const seen = new Set();
735
+ for (const candidate of candidates) {
736
+ const value = normalizeSpacing(candidate);
737
+ if (!value) {
738
+ continue;
739
+ }
740
+ const lower = value.toLowerCase();
741
+ if (seen.has(lower)) {
742
+ continue;
743
+ }
744
+ if (!matcher(value, lower)) {
745
+ continue;
746
+ }
747
+ seen.add(lower);
748
+ suggestions.push(value);
749
+ if (suggestions.length >= limit) {
750
+ break;
751
+ }
752
+ }
753
+ return suggestions;
754
+ }
755
+ function buildMealDashCoreVariants(prefixCore) {
756
+ var _a;
757
+ if (!prefixCore.includes("-") || prefixCore.includes("--")) {
758
+ return [];
759
+ }
760
+ const slots = prefixCore.split("-");
761
+ if (slots.length < 2 || slots.length > 4) {
762
+ return [];
763
+ }
764
+ if (!/^[0-9]+(?:\.[0-9]+)?$/.test((_a = slots[0]) !== null && _a !== void 0 ? _a : "")) {
765
+ return [];
766
+ }
767
+ for (let i = 1; i < slots.length; i += 1) {
768
+ const slot = slots[i];
769
+ if (slot.length === 0) {
770
+ continue;
771
+ }
772
+ if (!/^[0-9]+(?:\.[0-9]+)?$/.test(slot)) {
773
+ return [];
774
+ }
775
+ }
776
+ const variants = [];
777
+ const seen = new Set();
778
+ const addVariant = (value) => {
779
+ if (!seen.has(value)) {
780
+ seen.add(value);
781
+ variants.push(value);
782
+ }
783
+ };
784
+ const first = slots[0];
785
+ const fillBase = (targetLength) => {
786
+ const values = new Array(targetLength).fill("0");
787
+ values[0] = first;
788
+ for (let i = 1; i < targetLength; i += 1) {
789
+ if (i < slots.length && slots[i] !== "") {
790
+ values[i] = slots[i];
791
+ }
792
+ }
793
+ return values;
794
+ };
795
+ const base3 = fillBase(3);
796
+ const missingThird = slots.length < 3 || slots[2] === "";
797
+ if (missingThird && (slots.length === 1 || slots[1] === "" || slots[1] === "0")) {
798
+ const mirror = [...base3];
799
+ mirror[2] = first;
800
+ addVariant(mirror.join("-"));
801
+ }
802
+ addVariant(base3.join("-"));
803
+ const base4 = fillBase(4);
804
+ const missingFourth = slots.length < 4 || slots[3] === "";
805
+ if (missingFourth &&
806
+ (slots.length === 1 ||
807
+ slots[1] === "" ||
808
+ (slots[1] === "0" && (slots.length < 3 || slots[2] === "" || slots[2] === "0")))) {
809
+ const mirror = [...base4];
810
+ mirror[3] = first;
811
+ addVariant(mirror.join("-"));
812
+ }
813
+ addVariant(base4.join("-"));
814
+ return variants;
815
+ }
816
+ function suggestMealDashSyntax(prefix, limit, matcher) {
817
+ if (!prefix.includes("-")) {
818
+ return undefined;
819
+ }
820
+ const match = prefix.match(/^(\d+(?:-\d*){0,3})(?:\s+(ac|pc))?$/);
821
+ if (!match) {
822
+ return undefined;
823
+ }
824
+ const core = match[1];
825
+ const relation = match[2];
826
+ const coreVariants = buildMealDashCoreVariants(core);
827
+ if (coreVariants.length === 0) {
828
+ return undefined;
829
+ }
830
+ const suffixes = relation ? [` ${relation}`] : ["", " ac", " pc"];
831
+ const candidates = [];
832
+ for (const variant of coreVariants) {
833
+ for (const suffix of suffixes) {
834
+ candidates.push(`${variant}${suffix}`);
835
+ }
836
+ }
837
+ return collectMatchedCandidates(candidates, limit, matcher);
838
+ }
839
+ function suggestCompactOralMealTiming(prefix, limit, matcher) {
840
+ var _a, _b;
841
+ const match = prefix.match(/^(\d+(?:\.\d+)?)\s*(?:po\s*(c|ac|pc)|po(c|ac|pc))$/);
842
+ if (!match) {
843
+ return undefined;
844
+ }
845
+ const dose = normalizeSpacing(match[1]);
846
+ const timing = ((_b = (_a = match[2]) !== null && _a !== void 0 ? _a : match[3]) !== null && _b !== void 0 ? _b : "").toLowerCase();
847
+ const orderedTimings = timing === "c"
848
+ ? ["c", "ac", "pc"]
849
+ : timing === "ac"
850
+ ? ["ac", "c", "pc"]
851
+ : timing === "pc"
852
+ ? ["pc", "c", "ac"]
853
+ : ["c", "ac", "pc"];
854
+ const candidates = orderedTimings.map((token) => `${dose} po ${token}`);
855
+ return collectMatchedCandidates(candidates, limit, matcher);
856
+ }
732
857
  function suggestSig(input, options) {
733
858
  var _a, _b;
734
859
  const limit = (_a = options === null || options === void 0 ? void 0 : options.limit) !== null && _a !== void 0 ? _a : DEFAULT_LIMIT;
@@ -768,5 +893,15 @@ function suggestSig(input, options) {
768
893
  const timeTokens = buildTimeTokens(input);
769
894
  const whenSequences = PRECOMPUTED_WHEN_SEQUENCES;
770
895
  const matcher = (candidate, candidateLower) => matchesPrefix(candidate, candidateLower, prefixContext);
896
+ const compactOralSuggestions = suggestCompactOralMealTiming(prefix, limit, matcher);
897
+ if (compactOralSuggestions && compactOralSuggestions.length > 0) {
898
+ return compactOralSuggestions;
899
+ }
900
+ if (options === null || options === void 0 ? void 0 : options.enableMealDashSyntax) {
901
+ const mealDashSuggestions = suggestMealDashSyntax(prefix, limit, matcher);
902
+ if (mealDashSuggestions && mealDashSuggestions.length > 0) {
903
+ return mealDashSuggestions;
904
+ }
905
+ }
771
906
  return generateCandidateDirections(pairs, doseValues, prnReasons, intervalTokens, timeTokens, whenSequences, limit, matcher);
772
907
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ezmedicationinput",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Parse concise medication sigs into FHIR R5 Dosage JSON",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",