ezmedicationinput 0.1.0
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 +153 -0
- package/dist/context.d.ts +3 -0
- package/dist/context.js +29 -0
- package/dist/fhir.d.ts +4 -0
- package/dist/fhir.js +158 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.js +322 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +57 -0
- package/dist/maps.d.ts +51 -0
- package/dist/maps.js +729 -0
- package/dist/parser.d.ts +33 -0
- package/dist/parser.js +870 -0
- package/dist/safety.d.ts +8 -0
- package/dist/safety.js +12 -0
- package/dist/schedule.d.ts +6 -0
- package/dist/schedule.js +653 -0
- package/dist/types.d.ts +363 -0
- package/dist/types.js +224 -0
- package/package.json +32 -0
package/dist/safety.d.ts
ADDED
package/dist/safety.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { DISCOURAGED_TOKENS } from "./maps";
|
|
2
|
+
export function checkDiscouraged(token, options) {
|
|
3
|
+
const lower = token.toLowerCase();
|
|
4
|
+
if (!(lower in DISCOURAGED_TOKENS)) {
|
|
5
|
+
return { allowed: true };
|
|
6
|
+
}
|
|
7
|
+
const code = DISCOURAGED_TOKENS[lower];
|
|
8
|
+
if (options && options.allowDiscouraged === false) {
|
|
9
|
+
throw new Error(`Discouraged token '${token}' is not allowed`);
|
|
10
|
+
}
|
|
11
|
+
return { allowed: true, warning: `${code} is discouraged` };
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { FhirDosage, NextDueDoseOptions } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Produces the next dose timestamps in ascending order according to the
|
|
4
|
+
* provided configuration and dosage metadata.
|
|
5
|
+
*/
|
|
6
|
+
export declare function nextDueDoses(dosage: FhirDosage, options: NextDueDoseOptions): string[];
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { EventTiming } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Default institution times used when a dosage only specifies frequency without
|
|
4
|
+
* explicit EventTiming anchors. Clinics can override these through the
|
|
5
|
+
* configuration bag when desired.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_FREQUENCY_DEFAULTS = {
|
|
8
|
+
byCode: {
|
|
9
|
+
BID: ["08:00", "20:00"],
|
|
10
|
+
TID: ["08:00", "14:00", "20:00"],
|
|
11
|
+
QID: ["08:00", "12:00", "16:00", "20:00"],
|
|
12
|
+
QD: ["09:00"],
|
|
13
|
+
QOD: ["09:00"],
|
|
14
|
+
AM: ["08:00"],
|
|
15
|
+
PM: ["20:00"]
|
|
16
|
+
},
|
|
17
|
+
byFrequency: {
|
|
18
|
+
"freq:1/d": ["09:00"],
|
|
19
|
+
"freq:2/d": ["08:00", "20:00"],
|
|
20
|
+
"freq:3/d": ["08:00", "14:00", "20:00"],
|
|
21
|
+
"freq:4/d": ["08:00", "12:00", "16:00", "20:00"]
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const SECONDS_PER_MINUTE = 60;
|
|
25
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
26
|
+
/** Caches expensive Intl.DateTimeFormat objects per time zone. */
|
|
27
|
+
const dateTimeFormatCache = new Map();
|
|
28
|
+
/** Separate cache for weekday formatting to avoid rebuilding formatters. */
|
|
29
|
+
const weekdayFormatCache = new Map();
|
|
30
|
+
/** Simple zero-padding helper for numeric components. */
|
|
31
|
+
function pad(value, length = 2) {
|
|
32
|
+
return value.toString().padStart(length, "0");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Normalizes HH:mm or HH:mm:ss clocks into a consistent HH:mm:ss string.
|
|
36
|
+
*/
|
|
37
|
+
function normalizeClock(clock) {
|
|
38
|
+
const parts = clock.split(":");
|
|
39
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
40
|
+
throw new Error(`Invalid clock value: ${clock}`);
|
|
41
|
+
}
|
|
42
|
+
const [hourPart, minutePart, secondPart] = [
|
|
43
|
+
parts[0],
|
|
44
|
+
parts[1],
|
|
45
|
+
parts[2] ?? "00"
|
|
46
|
+
];
|
|
47
|
+
const hour = Number(hourPart);
|
|
48
|
+
const minute = Number(minutePart);
|
|
49
|
+
const second = Number(secondPart);
|
|
50
|
+
if (Number.isNaN(hour) ||
|
|
51
|
+
Number.isNaN(minute) ||
|
|
52
|
+
Number.isNaN(second) ||
|
|
53
|
+
hour < 0 ||
|
|
54
|
+
hour > 23 ||
|
|
55
|
+
minute < 0 ||
|
|
56
|
+
minute > 59 ||
|
|
57
|
+
second < 0 ||
|
|
58
|
+
second > 59) {
|
|
59
|
+
throw new Error(`Invalid clock value: ${clock}`);
|
|
60
|
+
}
|
|
61
|
+
return `${pad(hour)}:${pad(minute)}:${pad(second)}`;
|
|
62
|
+
}
|
|
63
|
+
/** Retrieves (and caches) an Intl formatter for calendar components. */
|
|
64
|
+
function getDateTimeFormat(timeZone) {
|
|
65
|
+
let formatter = dateTimeFormatCache.get(timeZone);
|
|
66
|
+
if (!formatter) {
|
|
67
|
+
formatter = new Intl.DateTimeFormat("en-CA", {
|
|
68
|
+
timeZone,
|
|
69
|
+
calendar: "iso8601",
|
|
70
|
+
numberingSystem: "latn",
|
|
71
|
+
hour12: false,
|
|
72
|
+
year: "numeric",
|
|
73
|
+
month: "2-digit",
|
|
74
|
+
day: "2-digit",
|
|
75
|
+
hour: "2-digit",
|
|
76
|
+
minute: "2-digit",
|
|
77
|
+
second: "2-digit"
|
|
78
|
+
});
|
|
79
|
+
dateTimeFormatCache.set(timeZone, formatter);
|
|
80
|
+
}
|
|
81
|
+
return formatter;
|
|
82
|
+
}
|
|
83
|
+
/** Retrieves (and caches) a formatter for weekday lookups. */
|
|
84
|
+
function getWeekdayFormat(timeZone) {
|
|
85
|
+
let formatter = weekdayFormatCache.get(timeZone);
|
|
86
|
+
if (!formatter) {
|
|
87
|
+
formatter = new Intl.DateTimeFormat("en-CA", {
|
|
88
|
+
timeZone,
|
|
89
|
+
weekday: "short"
|
|
90
|
+
});
|
|
91
|
+
weekdayFormatCache.set(timeZone, formatter);
|
|
92
|
+
}
|
|
93
|
+
return formatter;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Extracts calendar components for a Date interpreted within the supplied time
|
|
97
|
+
* zone.
|
|
98
|
+
*/
|
|
99
|
+
function getTimeParts(date, timeZone) {
|
|
100
|
+
const formatter = getDateTimeFormat(timeZone);
|
|
101
|
+
const parts = {};
|
|
102
|
+
const rawParts = formatter.formatToParts(date);
|
|
103
|
+
for (const part of rawParts) {
|
|
104
|
+
if (part.type === "literal") {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (part.type === "year") {
|
|
108
|
+
parts.year = Number(part.value);
|
|
109
|
+
}
|
|
110
|
+
else if (part.type === "month") {
|
|
111
|
+
parts.month = Number(part.value);
|
|
112
|
+
}
|
|
113
|
+
else if (part.type === "day") {
|
|
114
|
+
parts.day = Number(part.value);
|
|
115
|
+
}
|
|
116
|
+
else if (part.type === "hour") {
|
|
117
|
+
parts.hour = Number(part.value);
|
|
118
|
+
}
|
|
119
|
+
else if (part.type === "minute") {
|
|
120
|
+
parts.minute = Number(part.value);
|
|
121
|
+
}
|
|
122
|
+
else if (part.type === "second") {
|
|
123
|
+
parts.second = Number(part.value);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (parts.hour === 24) {
|
|
127
|
+
// Some locales express midnight as 24:00 of the previous day. Nudge the
|
|
128
|
+
// instant forward slightly so we can capture the correct calendar date and
|
|
129
|
+
// reset the hour component back to zero.
|
|
130
|
+
const forward = new Date(date.getTime() + 60 * 1000);
|
|
131
|
+
const forwardParts = formatter.formatToParts(forward);
|
|
132
|
+
for (const part of forwardParts) {
|
|
133
|
+
if (part.type === "literal") {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (part.type === "year") {
|
|
137
|
+
parts.year = Number(part.value);
|
|
138
|
+
}
|
|
139
|
+
else if (part.type === "month") {
|
|
140
|
+
parts.month = Number(part.value);
|
|
141
|
+
}
|
|
142
|
+
else if (part.type === "day") {
|
|
143
|
+
parts.day = Number(part.value);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
parts.hour = 0;
|
|
147
|
+
parts.minute = parts.minute ?? 0;
|
|
148
|
+
parts.second = parts.second ?? 0;
|
|
149
|
+
}
|
|
150
|
+
if (parts.year === undefined ||
|
|
151
|
+
parts.month === undefined ||
|
|
152
|
+
parts.day === undefined ||
|
|
153
|
+
parts.hour === undefined ||
|
|
154
|
+
parts.minute === undefined ||
|
|
155
|
+
parts.second === undefined) {
|
|
156
|
+
throw new Error("Unable to resolve time parts for provided date");
|
|
157
|
+
}
|
|
158
|
+
return parts;
|
|
159
|
+
}
|
|
160
|
+
/** Calculates the time-zone offset in minutes for a given instant. */
|
|
161
|
+
function getOffset(date, timeZone) {
|
|
162
|
+
const { year, month, day, hour, minute, second } = getTimeParts(date, timeZone);
|
|
163
|
+
const zonedTime = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
164
|
+
return (zonedTime - date.getTime()) / (SECONDS_PER_MINUTE * 1000);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Renders an ISO-8601 string that reflects the provided time zone instead of
|
|
168
|
+
* defaulting to UTC.
|
|
169
|
+
*/
|
|
170
|
+
function formatZonedIso(date, timeZone) {
|
|
171
|
+
const { year, month, day, hour, minute, second } = getTimeParts(date, timeZone);
|
|
172
|
+
const offsetMinutes = getOffset(date, timeZone);
|
|
173
|
+
const offsetSign = offsetMinutes >= 0 ? "+" : "-";
|
|
174
|
+
const absoluteOffset = Math.abs(offsetMinutes);
|
|
175
|
+
const offsetHours = Math.floor(absoluteOffset / SECONDS_PER_MINUTE);
|
|
176
|
+
const offsetRemainder = absoluteOffset % SECONDS_PER_MINUTE;
|
|
177
|
+
return `${pad(year, 4)}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:${pad(second)}${offsetSign}${pad(offsetHours)}:${pad(offsetRemainder)}`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Builds a Date representing a local wall-clock time in the target time zone.
|
|
181
|
+
*/
|
|
182
|
+
function makeZonedDate(timeZone, year, month, day, hour, minute, second) {
|
|
183
|
+
const initialUtc = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
184
|
+
let candidate = new Date(initialUtc);
|
|
185
|
+
let offset = getOffset(candidate, timeZone);
|
|
186
|
+
candidate = new Date(initialUtc - offset * SECONDS_PER_MINUTE * 1000);
|
|
187
|
+
const recalculatedOffset = getOffset(candidate, timeZone);
|
|
188
|
+
if (recalculatedOffset !== offset) {
|
|
189
|
+
candidate = new Date(initialUtc - recalculatedOffset * SECONDS_PER_MINUTE * 1000);
|
|
190
|
+
}
|
|
191
|
+
const parts = getTimeParts(candidate, timeZone);
|
|
192
|
+
if (parts.year !== year ||
|
|
193
|
+
parts.month !== month ||
|
|
194
|
+
parts.day !== day ||
|
|
195
|
+
parts.hour !== hour ||
|
|
196
|
+
parts.minute !== minute ||
|
|
197
|
+
parts.second !== second) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return candidate;
|
|
201
|
+
}
|
|
202
|
+
/** Convenience wrapper around makeZonedDate for day-level math. */
|
|
203
|
+
function makeZonedDateFromDay(base, timeZone, clock) {
|
|
204
|
+
const { year, month, day } = getTimeParts(base, timeZone);
|
|
205
|
+
const [hour, minute, second] = clock.split(":").map((value) => Number(value));
|
|
206
|
+
return makeZonedDate(timeZone, year, month, day, hour, minute, second);
|
|
207
|
+
}
|
|
208
|
+
/** Returns a Date pinned to the start of the local day. */
|
|
209
|
+
function startOfLocalDay(date, timeZone) {
|
|
210
|
+
const { year, month, day } = getTimeParts(date, timeZone);
|
|
211
|
+
const zoned = makeZonedDate(timeZone, year, month, day, 0, 0, 0);
|
|
212
|
+
if (!zoned) {
|
|
213
|
+
throw new Error("Unable to resolve start of day for provided date");
|
|
214
|
+
}
|
|
215
|
+
return zoned;
|
|
216
|
+
}
|
|
217
|
+
/** Adds a number of calendar days while remaining aligned to the time zone. */
|
|
218
|
+
function addLocalDays(date, days, timeZone) {
|
|
219
|
+
const { year, month, day } = getTimeParts(date, timeZone);
|
|
220
|
+
const zoned = makeZonedDate(timeZone, year, month, day + days, 0, 0, 0);
|
|
221
|
+
if (!zoned) {
|
|
222
|
+
throw new Error("Unable to shift local day – invalid calendar combination");
|
|
223
|
+
}
|
|
224
|
+
return zoned;
|
|
225
|
+
}
|
|
226
|
+
/** Computes the local weekday token (mon..sun). */
|
|
227
|
+
function getLocalWeekday(date, timeZone) {
|
|
228
|
+
const formatted = getWeekdayFormat(timeZone).format(date);
|
|
229
|
+
switch (formatted.toLowerCase()) {
|
|
230
|
+
case "mon":
|
|
231
|
+
return "mon";
|
|
232
|
+
case "tue":
|
|
233
|
+
return "tue";
|
|
234
|
+
case "wed":
|
|
235
|
+
return "wed";
|
|
236
|
+
case "thu":
|
|
237
|
+
return "thu";
|
|
238
|
+
case "fri":
|
|
239
|
+
return "fri";
|
|
240
|
+
case "sat":
|
|
241
|
+
return "sat";
|
|
242
|
+
case "sun":
|
|
243
|
+
return "sun";
|
|
244
|
+
default:
|
|
245
|
+
throw new Error(`Unexpected weekday token: ${formatted}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Parses arbitrary string/Date inputs into a valid Date instance. */
|
|
249
|
+
function coerceDate(value, label) {
|
|
250
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
251
|
+
if (Number.isNaN(date.getTime())) {
|
|
252
|
+
throw new Error(`Invalid ${label} supplied to nextDueDoses`);
|
|
253
|
+
}
|
|
254
|
+
return date;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Applies a minute offset to a normalized HH:mm:ss clock, tracking any day
|
|
258
|
+
* rollover that might occur.
|
|
259
|
+
*/
|
|
260
|
+
function applyOffset(clock, offsetMinutes) {
|
|
261
|
+
const [hour, minute, second] = clock.split(":").map((part) => Number(part));
|
|
262
|
+
let totalMinutes = hour * SECONDS_PER_MINUTE + minute + offsetMinutes;
|
|
263
|
+
let dayShift = 0;
|
|
264
|
+
while (totalMinutes < 0) {
|
|
265
|
+
totalMinutes += MINUTES_PER_DAY;
|
|
266
|
+
dayShift -= 1;
|
|
267
|
+
}
|
|
268
|
+
while (totalMinutes >= MINUTES_PER_DAY) {
|
|
269
|
+
totalMinutes -= MINUTES_PER_DAY;
|
|
270
|
+
dayShift += 1;
|
|
271
|
+
}
|
|
272
|
+
const adjustedHour = Math.floor(totalMinutes / SECONDS_PER_MINUTE);
|
|
273
|
+
const adjustedMinute = totalMinutes % SECONDS_PER_MINUTE;
|
|
274
|
+
return {
|
|
275
|
+
time: `${pad(adjustedHour)}:${pad(adjustedMinute)}:${pad(second)}`,
|
|
276
|
+
dayShift
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Provides the default meal pairing used for AC/PC expansions. */
|
|
280
|
+
function getDefaultMealPairs(config) {
|
|
281
|
+
return [EventTiming.Breakfast, EventTiming.Lunch, EventTiming.Dinner];
|
|
282
|
+
}
|
|
283
|
+
const SPECIFIC_BEFORE_MEALS = {
|
|
284
|
+
[EventTiming["Before Breakfast"]]: EventTiming.Breakfast,
|
|
285
|
+
[EventTiming["Before Lunch"]]: EventTiming.Lunch,
|
|
286
|
+
[EventTiming["Before Dinner"]]: EventTiming.Dinner
|
|
287
|
+
};
|
|
288
|
+
const SPECIFIC_AFTER_MEALS = {
|
|
289
|
+
[EventTiming["After Breakfast"]]: EventTiming.Breakfast,
|
|
290
|
+
[EventTiming["After Lunch"]]: EventTiming.Lunch,
|
|
291
|
+
[EventTiming["After Dinner"]]: EventTiming.Dinner
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* Expands a single EventTiming code into concrete wall-clock entries.
|
|
295
|
+
*/
|
|
296
|
+
function expandTiming(code, config, repeat) {
|
|
297
|
+
const mealOffsets = config.mealOffsets ?? {};
|
|
298
|
+
const normalized = [];
|
|
299
|
+
const clockValue = config.eventClock[code];
|
|
300
|
+
if (clockValue) {
|
|
301
|
+
normalized.push({ time: normalizeClock(clockValue), dayShift: 0 });
|
|
302
|
+
}
|
|
303
|
+
else if (code === EventTiming["Before Meal"]) {
|
|
304
|
+
for (const meal of getDefaultMealPairs(config)) {
|
|
305
|
+
const base = config.eventClock[meal];
|
|
306
|
+
if (!base) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
normalized.push(applyOffset(normalizeClock(base), mealOffsets[code] ?? 0));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else if (code === EventTiming["After Meal"]) {
|
|
313
|
+
for (const meal of getDefaultMealPairs(config)) {
|
|
314
|
+
const base = config.eventClock[meal];
|
|
315
|
+
if (!base) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
normalized.push(applyOffset(normalizeClock(base), mealOffsets[code] ?? 0));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else if (code === EventTiming.Meal) {
|
|
322
|
+
for (const meal of getDefaultMealPairs(config)) {
|
|
323
|
+
const base = config.eventClock[meal];
|
|
324
|
+
if (!base) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
normalized.push({ time: normalizeClock(base), dayShift: 0 });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else if (code in SPECIFIC_BEFORE_MEALS) {
|
|
331
|
+
const mealCode = SPECIFIC_BEFORE_MEALS[code];
|
|
332
|
+
const base = config.eventClock[mealCode];
|
|
333
|
+
if (base) {
|
|
334
|
+
const baseClock = normalizeClock(base);
|
|
335
|
+
const offset = mealOffsets[code] ?? mealOffsets[EventTiming["Before Meal"]] ?? 0;
|
|
336
|
+
normalized.push(offset ? applyOffset(baseClock, offset) : { time: baseClock, dayShift: 0 });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (code in SPECIFIC_AFTER_MEALS) {
|
|
340
|
+
const mealCode = SPECIFIC_AFTER_MEALS[code];
|
|
341
|
+
const base = config.eventClock[mealCode];
|
|
342
|
+
if (base) {
|
|
343
|
+
const baseClock = normalizeClock(base);
|
|
344
|
+
const offset = mealOffsets[code] ?? mealOffsets[EventTiming["After Meal"]] ?? 0;
|
|
345
|
+
normalized.push(offset ? applyOffset(baseClock, offset) : { time: baseClock, dayShift: 0 });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (repeat.offset && normalized.length) {
|
|
349
|
+
return normalized.map((entry) => {
|
|
350
|
+
const adjusted = applyOffset(entry.time, repeat.offset ?? 0);
|
|
351
|
+
return {
|
|
352
|
+
time: adjusted.time,
|
|
353
|
+
dayShift: entry.dayShift + adjusted.dayShift
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return normalized;
|
|
358
|
+
}
|
|
359
|
+
/** Consolidates EventTiming arrays into a deduplicated/sorted clock list. */
|
|
360
|
+
function expandWhenCodes(whenCodes, config, repeat) {
|
|
361
|
+
const entries = [];
|
|
362
|
+
const seen = new Set();
|
|
363
|
+
for (const code of whenCodes) {
|
|
364
|
+
if (code === EventTiming.Immediate) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const expansions = expandTiming(code, config, repeat);
|
|
368
|
+
for (const expansion of expansions) {
|
|
369
|
+
const key = `${expansion.dayShift}|${expansion.time}`;
|
|
370
|
+
if (seen.has(key)) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
seen.add(key);
|
|
374
|
+
entries.push(expansion);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return entries.sort((a, b) => {
|
|
378
|
+
if (a.dayShift !== b.dayShift) {
|
|
379
|
+
return a.dayShift - b.dayShift;
|
|
380
|
+
}
|
|
381
|
+
return a.time.localeCompare(b.time);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/** Resolves fallback clock arrays for frequency-only schedules. */
|
|
385
|
+
function resolveFrequencyClocks(timing, config) {
|
|
386
|
+
const defaults = {
|
|
387
|
+
byCode: {
|
|
388
|
+
...DEFAULT_FREQUENCY_DEFAULTS.byCode,
|
|
389
|
+
...(config.frequencyDefaults?.byCode ?? {})
|
|
390
|
+
},
|
|
391
|
+
byFrequency: {
|
|
392
|
+
...DEFAULT_FREQUENCY_DEFAULTS.byFrequency,
|
|
393
|
+
...(config.frequencyDefaults?.byFrequency ?? {})
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const collected = new Set();
|
|
397
|
+
const code = timing.code?.coding?.find((coding) => coding.code)?.code;
|
|
398
|
+
const normalizedCode = code?.toUpperCase();
|
|
399
|
+
if (normalizedCode && defaults.byCode?.[normalizedCode]) {
|
|
400
|
+
for (const clock of defaults.byCode[normalizedCode]) {
|
|
401
|
+
collected.add(normalizeClock(clock));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const repeat = timing.repeat;
|
|
405
|
+
if (repeat?.frequency && repeat.period && repeat.periodUnit) {
|
|
406
|
+
const key = `freq:${repeat.frequency}/${repeat.periodUnit}`;
|
|
407
|
+
if (defaults.byFrequency?.[key]) {
|
|
408
|
+
for (const clock of defaults.byFrequency[key]) {
|
|
409
|
+
collected.add(normalizeClock(clock));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const perPeriodKey = `freq:${repeat.frequency}/per:${repeat.period}${repeat.periodUnit}`;
|
|
413
|
+
if (defaults.byFrequency?.[perPeriodKey]) {
|
|
414
|
+
for (const clock of defaults.byFrequency[perPeriodKey]) {
|
|
415
|
+
collected.add(normalizeClock(clock));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return Array.from(collected).sort();
|
|
420
|
+
}
|
|
421
|
+
/** Determines the greater of orderedAt/from for baseline comparisons. */
|
|
422
|
+
function computeBaseline(orderedAt, from) {
|
|
423
|
+
return orderedAt > from ? orderedAt : from;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Produces the next dose timestamps in ascending order according to the
|
|
427
|
+
* provided configuration and dosage metadata.
|
|
428
|
+
*/
|
|
429
|
+
export function nextDueDoses(dosage, options) {
|
|
430
|
+
if (!options || typeof options !== "object") {
|
|
431
|
+
throw new Error("Options argument is required for nextDueDoses");
|
|
432
|
+
}
|
|
433
|
+
const { limit } = options;
|
|
434
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
const orderedAt = coerceDate(options.orderedAt, "orderedAt");
|
|
438
|
+
const from = coerceDate(options.from, "from");
|
|
439
|
+
const { config } = options;
|
|
440
|
+
if (!config || !config.timeZone) {
|
|
441
|
+
throw new Error("Configuration with a valid timeZone is required");
|
|
442
|
+
}
|
|
443
|
+
const timeZone = config.timeZone;
|
|
444
|
+
const timing = dosage.timing;
|
|
445
|
+
const repeat = timing?.repeat;
|
|
446
|
+
if (!timing || !repeat) {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
const baseline = computeBaseline(orderedAt, from);
|
|
450
|
+
const results = [];
|
|
451
|
+
const seen = new Set();
|
|
452
|
+
const dayFilter = new Set((repeat.dayOfWeek ?? []).map((day) => day.toLowerCase()));
|
|
453
|
+
const enforceDayFilter = dayFilter.size > 0;
|
|
454
|
+
const whenCodes = repeat.when ?? [];
|
|
455
|
+
const timeOfDayEntries = repeat.timeOfDay ?? [];
|
|
456
|
+
if (whenCodes.length > 0 || timeOfDayEntries.length > 0) {
|
|
457
|
+
const expanded = expandWhenCodes(whenCodes, config, repeat);
|
|
458
|
+
if (timeOfDayEntries.length > 0) {
|
|
459
|
+
for (const clock of timeOfDayEntries) {
|
|
460
|
+
expanded.push({ time: normalizeClock(clock), dayShift: 0 });
|
|
461
|
+
}
|
|
462
|
+
expanded.sort((a, b) => {
|
|
463
|
+
if (a.dayShift !== b.dayShift) {
|
|
464
|
+
return a.dayShift - b.dayShift;
|
|
465
|
+
}
|
|
466
|
+
return a.time.localeCompare(b.time);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const includesImmediate = whenCodes.includes(EventTiming.Immediate);
|
|
470
|
+
if (includesImmediate && orderedAt >= baseline) {
|
|
471
|
+
const instantIso = formatZonedIso(orderedAt, timeZone);
|
|
472
|
+
results.push(instantIso);
|
|
473
|
+
seen.add(instantIso);
|
|
474
|
+
}
|
|
475
|
+
if (expanded.length === 0) {
|
|
476
|
+
return results.slice(0, limit);
|
|
477
|
+
}
|
|
478
|
+
let currentDay = startOfLocalDay(from, timeZone);
|
|
479
|
+
let iterations = 0;
|
|
480
|
+
const maxIterations = limit * 31;
|
|
481
|
+
while (results.length < limit && iterations < maxIterations) {
|
|
482
|
+
const weekday = getLocalWeekday(currentDay, timeZone);
|
|
483
|
+
if (!enforceDayFilter || dayFilter.has(weekday)) {
|
|
484
|
+
for (const entry of expanded) {
|
|
485
|
+
const targetDay = entry.dayShift === 0
|
|
486
|
+
? currentDay
|
|
487
|
+
: addLocalDays(currentDay, entry.dayShift, timeZone);
|
|
488
|
+
const zoned = makeZonedDateFromDay(targetDay, timeZone, entry.time);
|
|
489
|
+
if (!zoned) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (zoned < baseline) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (zoned < orderedAt) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const iso = formatZonedIso(zoned, timeZone);
|
|
499
|
+
if (!seen.has(iso)) {
|
|
500
|
+
seen.add(iso);
|
|
501
|
+
results.push(iso);
|
|
502
|
+
if (results.length === limit) {
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
currentDay = addLocalDays(currentDay, 1, timeZone);
|
|
509
|
+
iterations += 1;
|
|
510
|
+
}
|
|
511
|
+
return results.slice(0, limit);
|
|
512
|
+
}
|
|
513
|
+
const treatAsInterval = !!repeat.period &&
|
|
514
|
+
!!repeat.periodUnit &&
|
|
515
|
+
(!repeat.frequency ||
|
|
516
|
+
repeat.periodUnit !== "d" ||
|
|
517
|
+
(repeat.frequency === 1 && repeat.period > 1));
|
|
518
|
+
if (treatAsInterval) {
|
|
519
|
+
// True interval schedules advance from the order start in fixed units. The
|
|
520
|
+
// timing.code remains advisory so we only rely on the period/unit fields.
|
|
521
|
+
const candidates = generateIntervalSeries(orderedAt, from, limit, repeat, timeZone, dayFilter, enforceDayFilter);
|
|
522
|
+
return candidates;
|
|
523
|
+
}
|
|
524
|
+
if (repeat.frequency && repeat.period && repeat.periodUnit) {
|
|
525
|
+
// Pure frequency schedules (e.g., BID/TID) rely on institution clocks that
|
|
526
|
+
// clinicians expect. These can be overridden via configuration when
|
|
527
|
+
// facilities use bespoke medication rounds.
|
|
528
|
+
const clocks = resolveFrequencyClocks(timing, config);
|
|
529
|
+
if (clocks.length === 0) {
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
let currentDay = startOfLocalDay(from, timeZone);
|
|
533
|
+
let iterations = 0;
|
|
534
|
+
const maxIterations = limit * 31;
|
|
535
|
+
while (results.length < limit && iterations < maxIterations) {
|
|
536
|
+
const weekday = getLocalWeekday(currentDay, timeZone);
|
|
537
|
+
if (!enforceDayFilter || dayFilter.has(weekday)) {
|
|
538
|
+
for (const clock of clocks) {
|
|
539
|
+
const zoned = makeZonedDateFromDay(currentDay, timeZone, clock);
|
|
540
|
+
if (!zoned) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (zoned < baseline || zoned < orderedAt) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const iso = formatZonedIso(zoned, timeZone);
|
|
547
|
+
if (!seen.has(iso)) {
|
|
548
|
+
seen.add(iso);
|
|
549
|
+
results.push(iso);
|
|
550
|
+
if (results.length === limit) {
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
currentDay = addLocalDays(currentDay, 1, timeZone);
|
|
557
|
+
iterations += 1;
|
|
558
|
+
}
|
|
559
|
+
return results.slice(0, limit);
|
|
560
|
+
}
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Generates an interval-based series by stepping forward from orderedAt until
|
|
565
|
+
* the requested number of timestamps have been produced.
|
|
566
|
+
*/
|
|
567
|
+
function generateIntervalSeries(orderedAt, from, limit, repeat, timeZone, dayFilter, enforceDayFilter) {
|
|
568
|
+
const increment = createIntervalStepper(repeat, timeZone);
|
|
569
|
+
if (!increment) {
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
const results = [];
|
|
573
|
+
const seen = new Set();
|
|
574
|
+
let current = orderedAt;
|
|
575
|
+
const baseline = computeBaseline(orderedAt, from);
|
|
576
|
+
let guard = 0;
|
|
577
|
+
const maxIterations = limit * 1000;
|
|
578
|
+
while (current < baseline && guard < maxIterations) {
|
|
579
|
+
const next = increment(current);
|
|
580
|
+
if (!next || next.getTime() === current.getTime()) {
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
current = next;
|
|
584
|
+
guard += 1;
|
|
585
|
+
}
|
|
586
|
+
while (results.length < limit && guard < maxIterations) {
|
|
587
|
+
const weekday = getLocalWeekday(current, timeZone);
|
|
588
|
+
if (!enforceDayFilter || dayFilter.has(weekday)) {
|
|
589
|
+
const iso = formatZonedIso(current, timeZone);
|
|
590
|
+
if (!seen.has(iso)) {
|
|
591
|
+
seen.add(iso);
|
|
592
|
+
results.push(iso);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const next = increment(current);
|
|
596
|
+
if (!next || next.getTime() === current.getTime()) {
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
current = next;
|
|
600
|
+
guard += 1;
|
|
601
|
+
}
|
|
602
|
+
return results.slice(0, limit);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Builds a function that advances a Date according to repeat.period/unit.
|
|
606
|
+
*/
|
|
607
|
+
function createIntervalStepper(repeat, timeZone) {
|
|
608
|
+
const { period, periodUnit } = repeat;
|
|
609
|
+
if (!period || !periodUnit) {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
if (periodUnit === "s" || periodUnit === "min" || periodUnit === "h") {
|
|
613
|
+
const multiplier = periodUnit === "s" ? 1000 : periodUnit === "min" ? 60 * 1000 : 60 * 60 * 1000;
|
|
614
|
+
const delta = period * multiplier;
|
|
615
|
+
return (value) => new Date(value.getTime() + delta);
|
|
616
|
+
}
|
|
617
|
+
if (periodUnit === "d") {
|
|
618
|
+
const delta = period * 24 * 60 * 60 * 1000;
|
|
619
|
+
return (value) => new Date(value.getTime() + delta);
|
|
620
|
+
}
|
|
621
|
+
if (periodUnit === "wk") {
|
|
622
|
+
const delta = period * 7 * 24 * 60 * 60 * 1000;
|
|
623
|
+
return (value) => new Date(value.getTime() + delta);
|
|
624
|
+
}
|
|
625
|
+
if (periodUnit === "mo") {
|
|
626
|
+
return (value) => addCalendarMonths(value, period, timeZone);
|
|
627
|
+
}
|
|
628
|
+
if (periodUnit === "a") {
|
|
629
|
+
return (value) => addCalendarMonths(value, period * 12, timeZone);
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
/** Adds calendar months while respecting varying month lengths and DST. */
|
|
634
|
+
function addCalendarMonths(date, months, timeZone) {
|
|
635
|
+
const { year, month, day, hour, minute, second } = getTimeParts(date, timeZone);
|
|
636
|
+
const targetMonthIndex = month - 1 + months;
|
|
637
|
+
const targetYear = year + Math.floor(targetMonthIndex / 12);
|
|
638
|
+
const targetMonth = (targetMonthIndex % 12 + 12) % 12;
|
|
639
|
+
const candidate = makeZonedDate(timeZone, targetYear, targetMonth + 1, 1, hour, minute, second);
|
|
640
|
+
if (!candidate) {
|
|
641
|
+
throw new Error("Unable to compute candidate month while scheduling");
|
|
642
|
+
}
|
|
643
|
+
const lastDay = new Date(candidate.getTime());
|
|
644
|
+
lastDay.setUTCMonth(lastDay.getUTCMonth() + 1);
|
|
645
|
+
lastDay.setUTCDate(0);
|
|
646
|
+
const maxDay = getTimeParts(lastDay, timeZone).day;
|
|
647
|
+
const resolvedDay = Math.min(day, maxDay);
|
|
648
|
+
const final = makeZonedDate(timeZone, targetYear, targetMonth + 1, resolvedDay, hour, minute, second);
|
|
649
|
+
if (!final) {
|
|
650
|
+
throw new Error("Unable to resolve monthly advancement – invalid calendar date");
|
|
651
|
+
}
|
|
652
|
+
return final;
|
|
653
|
+
}
|