ezmedicationinput 0.1.37 → 0.1.39
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 +6 -0
- package/dist/index.js +93 -3
- package/dist/maps.js +1 -0
- package/dist/parser.js +76 -8
- package/dist/schedule.js +214 -24
- package/dist/suggest.js +135 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
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
|
|
|
@@ -346,6 +348,10 @@ You can specify the number of times (total count) the medication is supposed to
|
|
|
346
348
|
combinations (e.g. `1x3` → breakfast/lunch/dinner). This also respects
|
|
347
349
|
`context.mealRelation` when provided and only applies to schedules with four
|
|
348
350
|
or fewer daily doses.
|
|
351
|
+
- `enableMealDashSyntax`: when `true`, enables shorthand meal-dose patterns
|
|
352
|
+
such as `1-0-1`, `1-0-1 pc`, `10-12-0 ac`, and `1-0-0-1 ac`. The parser
|
|
353
|
+
expands them into multiple dosage clauses aligned to breakfast/lunch/dinner
|
|
354
|
+
(plus bedtime for a 4th slot).
|
|
349
355
|
- `twoPerDayPair`: controls whether 2× AC/PC/C doses expand to breakfast+dinner (default) or breakfast+lunch.
|
|
350
356
|
- `assumeSingleDiscreteDose`: when `true`, missing discrete doses (such as
|
|
351
357
|
tablets or capsules) default to a single unit when the parser can infer a
|
package/dist/index.js
CHANGED
|
@@ -58,6 +58,96 @@ 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
|
+
function parseMealDashValues(token) {
|
|
62
|
+
if (!/^[0-9]+(?:\.[0-9]+)?(?:-[0-9]+(?:\.[0-9]+)?){2,3}$/.test(token)) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const values = token.split("-").map((part) => Number(part));
|
|
66
|
+
if (values.length !== 3 && values.length !== 4) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (!values.every((value) => Number.isFinite(value) && value >= 0)) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return values;
|
|
73
|
+
}
|
|
74
|
+
function mealDashEvents(length, relation) {
|
|
75
|
+
const base = relation === "ac"
|
|
76
|
+
? ["ACM", "ACD", "ACV"]
|
|
77
|
+
: relation === "pc"
|
|
78
|
+
? ["PCM", "PCD", "PCV"]
|
|
79
|
+
: ["CM", "CD", "CV"];
|
|
80
|
+
if (length === 4) {
|
|
81
|
+
return [...base, "HS"];
|
|
82
|
+
}
|
|
83
|
+
return base;
|
|
84
|
+
}
|
|
85
|
+
function formatMealDashAmount(value) {
|
|
86
|
+
if (Number.isInteger(value)) {
|
|
87
|
+
return String(value);
|
|
88
|
+
}
|
|
89
|
+
return String(value).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
|
|
90
|
+
}
|
|
91
|
+
function expandMealDashSegment(segment) {
|
|
92
|
+
const tokens = (0, parser_1.tokenize)(segment.text);
|
|
93
|
+
if (tokens.length === 0) {
|
|
94
|
+
return [segment];
|
|
95
|
+
}
|
|
96
|
+
const firstToken = tokens[0];
|
|
97
|
+
const values = parseMealDashValues(firstToken.lower);
|
|
98
|
+
if (!values) {
|
|
99
|
+
return [segment];
|
|
100
|
+
}
|
|
101
|
+
let relation = "meal";
|
|
102
|
+
let relationIndex = -1;
|
|
103
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
104
|
+
const lower = tokens[i].lower.replace(/[.,;:]/g, "");
|
|
105
|
+
if (lower === "ac") {
|
|
106
|
+
relation = "ac";
|
|
107
|
+
relationIndex = i;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
if (lower === "pc") {
|
|
111
|
+
relation = "pc";
|
|
112
|
+
relationIndex = i;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const suffixTokens = tokens
|
|
117
|
+
.filter((token, index) => index !== 0 && index !== relationIndex)
|
|
118
|
+
.map((token) => token.original);
|
|
119
|
+
const events = mealDashEvents(values.length, relation);
|
|
120
|
+
const expanded = values
|
|
121
|
+
.map((value, index) => ({ value, event: events[index] }))
|
|
122
|
+
.filter(({ value }) => value > 0)
|
|
123
|
+
.map(({ value, event }) => {
|
|
124
|
+
const text = [formatMealDashAmount(value), ...suffixTokens, event]
|
|
125
|
+
.filter((part) => part && part.trim().length > 0)
|
|
126
|
+
.join(" ")
|
|
127
|
+
.replace(/\s+/g, " ")
|
|
128
|
+
.trim();
|
|
129
|
+
return {
|
|
130
|
+
text,
|
|
131
|
+
start: segment.start,
|
|
132
|
+
end: segment.end
|
|
133
|
+
};
|
|
134
|
+
})
|
|
135
|
+
.filter((item) => item.text.length > 0);
|
|
136
|
+
if (expanded.length === 0) {
|
|
137
|
+
return [segment];
|
|
138
|
+
}
|
|
139
|
+
return expanded;
|
|
140
|
+
}
|
|
141
|
+
function expandMealDashSegments(segments, options) {
|
|
142
|
+
if (!(options === null || options === void 0 ? void 0 : options.enableMealDashSyntax)) {
|
|
143
|
+
return segments;
|
|
144
|
+
}
|
|
145
|
+
const expanded = [];
|
|
146
|
+
for (const segment of segments) {
|
|
147
|
+
expanded.push(...expandMealDashSegment(segment));
|
|
148
|
+
}
|
|
149
|
+
return expanded;
|
|
150
|
+
}
|
|
61
151
|
function toSegmentMeta(segments) {
|
|
62
152
|
return segments.map((segment, index) => ({
|
|
63
153
|
index,
|
|
@@ -66,7 +156,7 @@ function toSegmentMeta(segments) {
|
|
|
66
156
|
}));
|
|
67
157
|
}
|
|
68
158
|
function parseSig(input, options) {
|
|
69
|
-
const segments = (0, segment_1.splitSigSegments)(input);
|
|
159
|
+
const segments = expandMealDashSegments((0, segment_1.splitSigSegments)(input), options);
|
|
70
160
|
const carry = {};
|
|
71
161
|
const results = [];
|
|
72
162
|
for (const segment of segments) {
|
|
@@ -92,7 +182,7 @@ function parseSig(input, options) {
|
|
|
92
182
|
};
|
|
93
183
|
}
|
|
94
184
|
function lintSig(input, options) {
|
|
95
|
-
const segments = (0, segment_1.splitSigSegments)(input);
|
|
185
|
+
const segments = expandMealDashSegments((0, segment_1.splitSigSegments)(input), options);
|
|
96
186
|
const carry = {};
|
|
97
187
|
const results = [];
|
|
98
188
|
for (const segment of segments) {
|
|
@@ -132,7 +222,7 @@ function lintSig(input, options) {
|
|
|
132
222
|
}
|
|
133
223
|
function parseSigAsync(input, options) {
|
|
134
224
|
return __awaiter(this, void 0, void 0, function* () {
|
|
135
|
-
const segments = (0, segment_1.splitSigSegments)(input);
|
|
225
|
+
const segments = expandMealDashSegments((0, segment_1.splitSigSegments)(input), options);
|
|
136
226
|
const carry = {};
|
|
137
227
|
const results = [];
|
|
138
228
|
for (const segment of segments) {
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
1967
|
-
if (
|
|
2034
|
+
if (isAtPrefixToken(token.lower) || token.lower === "on" || token.lower === "with") {
|
|
2035
|
+
if (tryParseTimeBasedSchedule(internal, tokens, i)) {
|
|
1968
2036
|
continue;
|
|
1969
2037
|
}
|
|
1970
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 &&
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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/dist/types.d.ts
CHANGED
|
@@ -457,6 +457,13 @@ export interface ParseOptions extends FormatOptions {
|
|
|
457
457
|
* `context.mealRelation` when provided.
|
|
458
458
|
*/
|
|
459
459
|
smartMealExpansion?: boolean;
|
|
460
|
+
/**
|
|
461
|
+
* Enables parsing meal dash shorthand like `1-0-1` / `1-0-0-1` into
|
|
462
|
+
* multiple dosage clauses aligned to breakfast/lunch/dinner/(bedtime).
|
|
463
|
+
* Optional trailing `ac` / `pc` maps meal anchors to before/after meal
|
|
464
|
+
* variants.
|
|
465
|
+
*/
|
|
466
|
+
enableMealDashSyntax?: boolean;
|
|
460
467
|
/**
|
|
461
468
|
* Controls which meal pair is assumed for twice-daily meal expansions.
|
|
462
469
|
* Defaults to "breakfast+dinner" to mirror common clinical practice.
|