@apiary-dev/core 0.0.0-stage → 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/LICENSE +15 -0
- package/README.md +59 -2
- package/dist/index.cjs +419 -0
- package/dist/index.d.cts +219 -0
- package/dist/index.d.ts +219 -0
- package/dist/index.js +383 -0
- package/package.json +30 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IngeniumOfficial
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @apiary-dev/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zero-dependency, framework-agnostic scheduling domain logic: slot generation,
|
|
4
|
+
collision and buffer math, timezone-safe weekly rules, and headless calendar
|
|
5
|
+
grids.
|
|
6
|
+
|
|
7
|
+
- No runtime dependencies; built on `Date` and `Intl`
|
|
8
|
+
- TypeScript-first: bundled ESM/CJS with types on npm
|
|
9
|
+
- Runs on Node.js, Deno, Bun, and Cloudflare Workers
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @apiary-dev/core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Example
|
|
18
|
+
|
|
19
|
+
Carve an operating window into bookable slots:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { generateTimeSlots } from "@apiary-dev/core";
|
|
23
|
+
|
|
24
|
+
const slots = generateTimeSlots({
|
|
25
|
+
windows: [
|
|
26
|
+
{
|
|
27
|
+
start: new Date("2026-01-05T09:00:00Z"),
|
|
28
|
+
end: new Date("2026-01-05T17:00:00Z"),
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
existingBookings: [],
|
|
32
|
+
durationMinutes: 30,
|
|
33
|
+
intervalMinutes: 15,
|
|
34
|
+
buffers: { beforeMinutes: 5, afterMinutes: 5 },
|
|
35
|
+
minNoticeMinutes: 30,
|
|
36
|
+
maxHorizonMinutes: 7 * 24 * 60,
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Expand weekly recurring rules into windows:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { expandWeeklyRules } from "@apiary-dev/core";
|
|
44
|
+
|
|
45
|
+
const windows = expandWeeklyRules({
|
|
46
|
+
rules: [{ dayOfWeek: 1, startTime: "09:00", endTime: "17:00" }],
|
|
47
|
+
startDate: new Date("2026-01-05T00:00:00Z"),
|
|
48
|
+
endDate: new Date("2026-01-12T00:00:00Z"),
|
|
49
|
+
timeZone: "America/New_York",
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Documentation
|
|
54
|
+
|
|
55
|
+
Full API reference and guides are available at
|
|
56
|
+
[apiary-f4n.pages.dev/reference/core/](https://apiary-f4n.pages.dev/reference/core/).
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
ISC
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
applyBufferToRange: () => applyBufferToRange,
|
|
24
|
+
buildMonthMatrix: () => buildMonthMatrix,
|
|
25
|
+
buildMultiMonthMatrix: () => buildMultiMonthMatrix,
|
|
26
|
+
buildMultiWeekMatrix: () => buildMultiWeekMatrix,
|
|
27
|
+
buildWeekMatrix: () => buildWeekMatrix,
|
|
28
|
+
expandWeeklyRules: () => expandWeeklyRules,
|
|
29
|
+
generateTimeSlots: () => generateTimeSlots,
|
|
30
|
+
rangesOverlap: () => rangesOverlap,
|
|
31
|
+
utcToWallClock: () => utcToWallClock,
|
|
32
|
+
wallClockToUTC: () => wallClockToUTC
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
|
|
36
|
+
// src/rules/collisions.ts
|
|
37
|
+
function rangesOverlap(a, b) {
|
|
38
|
+
return a.start < b.end && a.end > b.start;
|
|
39
|
+
}
|
|
40
|
+
function applyBufferToRange(range, buffer) {
|
|
41
|
+
const beforeMs = (buffer?.beforeMinutes ?? 0) * 60 * 1e3;
|
|
42
|
+
const afterMs = (buffer?.afterMinutes ?? 0) * 60 * 1e3;
|
|
43
|
+
return {
|
|
44
|
+
start: new Date(range.start.getTime() - beforeMs),
|
|
45
|
+
end: new Date(range.end.getTime() + afterMs)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/engine/generator.ts
|
|
50
|
+
function generateTimeSlots(options, now = /* @__PURE__ */ new Date()) {
|
|
51
|
+
const {
|
|
52
|
+
windows,
|
|
53
|
+
existingBookings,
|
|
54
|
+
durationMinutes,
|
|
55
|
+
intervalMinutes,
|
|
56
|
+
buffers = {},
|
|
57
|
+
minNoticeMinutes = 0,
|
|
58
|
+
maxHorizonMinutes
|
|
59
|
+
} = options;
|
|
60
|
+
if (windows.length === 0) return [];
|
|
61
|
+
const slotDurationMs = durationMinutes * 60 * 1e3;
|
|
62
|
+
const stepIntervalMs = intervalMinutes * 60 * 1e3;
|
|
63
|
+
const bufferBeforeMs = (buffers.beforeMinutes ?? 0) * 60 * 1e3;
|
|
64
|
+
const bufferAfterMs = (buffers.afterMinutes ?? 0) * 60 * 1e3;
|
|
65
|
+
const minNoticeCutoff = new Date(
|
|
66
|
+
now.getTime() + minNoticeMinutes * 60 * 1e3
|
|
67
|
+
);
|
|
68
|
+
const maxHorizonCutoff = maxHorizonMinutes !== void 0 ? new Date(now.getTime() + maxHorizonMinutes * 60 * 1e3) : null;
|
|
69
|
+
const bufferedBookings = existingBookings.map(
|
|
70
|
+
(b) => applyBufferToRange(b, buffers)
|
|
71
|
+
);
|
|
72
|
+
const candidateSlots = [];
|
|
73
|
+
for (const window of windows) {
|
|
74
|
+
let currentStartMs = window.start.getTime();
|
|
75
|
+
const windowEndMs = window.end.getTime();
|
|
76
|
+
while (currentStartMs + slotDurationMs <= windowEndMs) {
|
|
77
|
+
const slotStart = new Date(currentStartMs);
|
|
78
|
+
const slotEnd = new Date(currentStartMs + slotDurationMs);
|
|
79
|
+
const bufferedStartMs = currentStartMs - bufferBeforeMs;
|
|
80
|
+
const bufferedEndMs = currentStartMs + slotDurationMs + bufferAfterMs;
|
|
81
|
+
let available = true;
|
|
82
|
+
let reason = "AVAILABLE";
|
|
83
|
+
if (slotStart < minNoticeCutoff) {
|
|
84
|
+
available = false;
|
|
85
|
+
reason = "MIN_NOTICE_VIOLATION";
|
|
86
|
+
}
|
|
87
|
+
if (available && maxHorizonCutoff !== null && slotStart.getTime() > maxHorizonCutoff.getTime()) {
|
|
88
|
+
available = false;
|
|
89
|
+
reason = "MAX_HORIZON_EXCEEDED";
|
|
90
|
+
}
|
|
91
|
+
if (available && (bufferedStartMs < window.start.getTime() || bufferedEndMs > windowEndMs)) {
|
|
92
|
+
available = false;
|
|
93
|
+
reason = "OUTSIDE_BOUNDS";
|
|
94
|
+
}
|
|
95
|
+
if (available) {
|
|
96
|
+
const candidateRange = { start: slotStart, end: slotEnd };
|
|
97
|
+
const bufferedCandidate = applyBufferToRange(candidateRange, buffers);
|
|
98
|
+
const hasCollision = bufferedBookings.some(
|
|
99
|
+
(booking) => rangesOverlap(bufferedCandidate, booking)
|
|
100
|
+
);
|
|
101
|
+
if (hasCollision) {
|
|
102
|
+
available = false;
|
|
103
|
+
reason = "COLLISION";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
candidateSlots.push({
|
|
107
|
+
start: slotStart,
|
|
108
|
+
end: slotEnd,
|
|
109
|
+
available,
|
|
110
|
+
reason
|
|
111
|
+
});
|
|
112
|
+
currentStartMs += stepIntervalMs;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return candidateSlots;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/engine/calendar.ts
|
|
119
|
+
var toDateKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
120
|
+
function buildMonthMatrix(options) {
|
|
121
|
+
const { year, month, weekStartsOn = 0 } = options;
|
|
122
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
123
|
+
const firstDayOfMonth = new Date(year, month - 1, 1);
|
|
124
|
+
let startOffset = firstDayOfMonth.getDay() - weekStartsOn;
|
|
125
|
+
if (startOffset < 0) startOffset += 7;
|
|
126
|
+
const startDate = new Date(firstDayOfMonth);
|
|
127
|
+
startDate.setDate(startDate.getDate() - startOffset);
|
|
128
|
+
const grid = [];
|
|
129
|
+
const currentDate = new Date(startDate);
|
|
130
|
+
for (let week = 0; week < 6; week++) {
|
|
131
|
+
const weekRow = [];
|
|
132
|
+
for (let day = 0; day < 7; day++) {
|
|
133
|
+
const isoString = toDateKey(currentDate);
|
|
134
|
+
const isCurrentMonth = currentDate.getMonth() === month - 1;
|
|
135
|
+
const dayOfWeek = currentDate.getDay();
|
|
136
|
+
weekRow.push({
|
|
137
|
+
date: new Date(currentDate),
|
|
138
|
+
isoString,
|
|
139
|
+
dayOfMonth: currentDate.getDate(),
|
|
140
|
+
dayOfWeek,
|
|
141
|
+
isCurrentMonth,
|
|
142
|
+
isToday: isoString === todayStr,
|
|
143
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
144
|
+
isBlocked: !isCurrentMonth,
|
|
145
|
+
blockReason: !isCurrentMonth ? "OUT_OF_BOUNDS" : void 0
|
|
146
|
+
});
|
|
147
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
148
|
+
}
|
|
149
|
+
grid.push(weekRow);
|
|
150
|
+
}
|
|
151
|
+
return grid;
|
|
152
|
+
}
|
|
153
|
+
function buildWeekMatrix(options) {
|
|
154
|
+
const { targetDate, weekStartsOn = 0 } = options;
|
|
155
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
156
|
+
const startOfWeek = new Date(targetDate);
|
|
157
|
+
let dayOffset = startOfWeek.getDay() - weekStartsOn;
|
|
158
|
+
if (dayOffset < 0) dayOffset += 7;
|
|
159
|
+
startOfWeek.setDate(startOfWeek.getDate() - dayOffset);
|
|
160
|
+
const week = [];
|
|
161
|
+
const currentDate = new Date(startOfWeek);
|
|
162
|
+
for (let i = 0; i < 7; i++) {
|
|
163
|
+
const isoString = toDateKey(currentDate);
|
|
164
|
+
const dayOfWeek = currentDate.getDay();
|
|
165
|
+
const isCurrentMonth = currentDate.getFullYear() === targetDate.getFullYear() && currentDate.getMonth() === targetDate.getMonth();
|
|
166
|
+
week.push({
|
|
167
|
+
date: new Date(currentDate),
|
|
168
|
+
isoString,
|
|
169
|
+
dayOfMonth: currentDate.getDate(),
|
|
170
|
+
dayOfWeek,
|
|
171
|
+
isCurrentMonth,
|
|
172
|
+
isToday: isoString === todayStr,
|
|
173
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
174
|
+
isBlocked: !isCurrentMonth,
|
|
175
|
+
blockReason: !isCurrentMonth ? "OUT_OF_BOUNDS" : void 0
|
|
176
|
+
});
|
|
177
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
178
|
+
}
|
|
179
|
+
return week;
|
|
180
|
+
}
|
|
181
|
+
function buildMultiWeekMatrix(options) {
|
|
182
|
+
const {
|
|
183
|
+
startDate,
|
|
184
|
+
endDate,
|
|
185
|
+
maxWeeksAllowed = 12,
|
|
186
|
+
weekStartsOn = 0
|
|
187
|
+
} = options;
|
|
188
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
189
|
+
const cursor = new Date(startDate);
|
|
190
|
+
let dayOffset = cursor.getDay() - weekStartsOn;
|
|
191
|
+
if (dayOffset < 0) dayOffset += 7;
|
|
192
|
+
cursor.setDate(cursor.getDate() - dayOffset);
|
|
193
|
+
const weeks = [];
|
|
194
|
+
let weekCount = 0;
|
|
195
|
+
const startMs = new Date(
|
|
196
|
+
startDate.getFullYear(),
|
|
197
|
+
startDate.getMonth(),
|
|
198
|
+
startDate.getDate()
|
|
199
|
+
).getTime();
|
|
200
|
+
const endMs = new Date(
|
|
201
|
+
endDate.getFullYear(),
|
|
202
|
+
endDate.getMonth(),
|
|
203
|
+
endDate.getDate()
|
|
204
|
+
).getTime();
|
|
205
|
+
while (cursor.getTime() <= endMs && weekCount < maxWeeksAllowed) {
|
|
206
|
+
const weekRow = [];
|
|
207
|
+
for (let i = 0; i < 7; i++) {
|
|
208
|
+
const currentMs = new Date(
|
|
209
|
+
cursor.getFullYear(),
|
|
210
|
+
cursor.getMonth(),
|
|
211
|
+
cursor.getDate()
|
|
212
|
+
).getTime();
|
|
213
|
+
const isoString = toDateKey(cursor);
|
|
214
|
+
const dayOfWeek = cursor.getDay();
|
|
215
|
+
const isOutOfBounds = currentMs < startMs || currentMs > endMs;
|
|
216
|
+
weekRow.push({
|
|
217
|
+
date: new Date(cursor),
|
|
218
|
+
isoString,
|
|
219
|
+
dayOfMonth: cursor.getDate(),
|
|
220
|
+
dayOfWeek,
|
|
221
|
+
isCurrentMonth: true,
|
|
222
|
+
isToday: isoString === todayStr,
|
|
223
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
224
|
+
isBlocked: isOutOfBounds,
|
|
225
|
+
blockReason: isOutOfBounds ? "OUT_OF_BOUNDS" : void 0
|
|
226
|
+
});
|
|
227
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
228
|
+
}
|
|
229
|
+
weeks.push(weekRow);
|
|
230
|
+
weekCount++;
|
|
231
|
+
}
|
|
232
|
+
return weeks;
|
|
233
|
+
}
|
|
234
|
+
function buildMultiMonthMatrix(options) {
|
|
235
|
+
const {
|
|
236
|
+
startDate,
|
|
237
|
+
endDate,
|
|
238
|
+
maxMonthsAllowed = 12,
|
|
239
|
+
weekStartsOn = 0
|
|
240
|
+
} = options;
|
|
241
|
+
const startMs = new Date(
|
|
242
|
+
startDate.getFullYear(),
|
|
243
|
+
startDate.getMonth(),
|
|
244
|
+
startDate.getDate()
|
|
245
|
+
).getTime();
|
|
246
|
+
const endMs = new Date(
|
|
247
|
+
endDate.getFullYear(),
|
|
248
|
+
endDate.getMonth(),
|
|
249
|
+
endDate.getDate()
|
|
250
|
+
).getTime();
|
|
251
|
+
const months = [];
|
|
252
|
+
let currentYear = startDate.getFullYear();
|
|
253
|
+
let currentMonth = startDate.getMonth() + 1;
|
|
254
|
+
let monthCount = 0;
|
|
255
|
+
const targetEndYear = endDate.getFullYear();
|
|
256
|
+
const targetEndMonth = endDate.getMonth() + 1;
|
|
257
|
+
while ((currentYear < targetEndYear || currentYear === targetEndYear && currentMonth <= targetEndMonth) && monthCount < maxMonthsAllowed) {
|
|
258
|
+
const baseGrid = buildMonthMatrix({
|
|
259
|
+
year: currentYear,
|
|
260
|
+
month: currentMonth,
|
|
261
|
+
weekStartsOn
|
|
262
|
+
});
|
|
263
|
+
const scopedGrid = baseGrid.map(
|
|
264
|
+
(weekRow) => weekRow.map((cell) => {
|
|
265
|
+
const cellMs = new Date(
|
|
266
|
+
cell.date.getFullYear(),
|
|
267
|
+
cell.date.getMonth(),
|
|
268
|
+
cell.date.getDate()
|
|
269
|
+
).getTime();
|
|
270
|
+
const isOutOfBounds = cellMs < startMs || cellMs > endMs || !cell.isCurrentMonth;
|
|
271
|
+
return {
|
|
272
|
+
...cell,
|
|
273
|
+
isBlocked: isOutOfBounds,
|
|
274
|
+
blockReason: isOutOfBounds ? "OUT_OF_BOUNDS" : void 0
|
|
275
|
+
};
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
months.push({
|
|
279
|
+
year: currentYear,
|
|
280
|
+
month: currentMonth,
|
|
281
|
+
grid: scopedGrid
|
|
282
|
+
});
|
|
283
|
+
monthCount++;
|
|
284
|
+
currentMonth++;
|
|
285
|
+
if (currentMonth > 12) {
|
|
286
|
+
currentMonth = 1;
|
|
287
|
+
currentYear++;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return months;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/engine/timezone.ts
|
|
294
|
+
var wallClockToUTC = (wallClock, timeZone) => {
|
|
295
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
296
|
+
const isoStr = `${wallClock.year}-${pad(wallClock.month)}-${pad(wallClock.day)}T${pad(wallClock.hour)}:${pad(wallClock.minute)}:00`;
|
|
297
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
298
|
+
timeZone,
|
|
299
|
+
year: "numeric",
|
|
300
|
+
month: "2-digit",
|
|
301
|
+
day: "2-digit",
|
|
302
|
+
hour: "2-digit",
|
|
303
|
+
minute: "2-digit",
|
|
304
|
+
second: "2-digit",
|
|
305
|
+
hour12: false
|
|
306
|
+
});
|
|
307
|
+
const utcGuess = /* @__PURE__ */ new Date(`${isoStr}Z`);
|
|
308
|
+
const parts = Object.fromEntries(
|
|
309
|
+
formatter.formatToParts(utcGuess).map((p) => [p.type, p.value])
|
|
310
|
+
);
|
|
311
|
+
const localHeader = `${parts.year}-${parts.month}-${parts.day}T${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}:${parts.second}`;
|
|
312
|
+
const diffMs = (/* @__PURE__ */ new Date(`${isoStr}Z`)).getTime() - (/* @__PURE__ */ new Date(`${localHeader}Z`)).getTime();
|
|
313
|
+
return new Date(utcGuess.getTime() + diffMs);
|
|
314
|
+
};
|
|
315
|
+
var utcToWallClock = (date, timeZone) => {
|
|
316
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
317
|
+
timeZone,
|
|
318
|
+
year: "numeric",
|
|
319
|
+
month: "2-digit",
|
|
320
|
+
day: "2-digit",
|
|
321
|
+
hour: "2-digit",
|
|
322
|
+
minute: "2-digit",
|
|
323
|
+
second: "2-digit",
|
|
324
|
+
hour12: false
|
|
325
|
+
});
|
|
326
|
+
const parts = Object.fromEntries(
|
|
327
|
+
formatter.formatToParts(date).map((p) => [p.type, p.value])
|
|
328
|
+
);
|
|
329
|
+
return {
|
|
330
|
+
year: Number(parts.year),
|
|
331
|
+
month: Number(parts.month),
|
|
332
|
+
day: Number(parts.day),
|
|
333
|
+
hour: parts.hour === "24" ? 0 : Number(parts.hour),
|
|
334
|
+
minute: Number(parts.minute)
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// src/engine/weekly.ts
|
|
339
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
340
|
+
var parseTime = (time) => {
|
|
341
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
|
342
|
+
if (!match) {
|
|
343
|
+
throw new Error(`Invalid time "${time}" \u2014 expected 24-hour "HH:mm".`);
|
|
344
|
+
}
|
|
345
|
+
const hour = Number(match[1]);
|
|
346
|
+
const minute = Number(match[2]);
|
|
347
|
+
if (hour > 23 || minute > 59) {
|
|
348
|
+
throw new Error(`Invalid time "${time}" \u2014 hour/minute out of range.`);
|
|
349
|
+
}
|
|
350
|
+
return { hour, minute };
|
|
351
|
+
};
|
|
352
|
+
var weekdayOf = (wallClock) => new Date(wallClock.year, wallClock.month - 1, wallClock.day).getDay();
|
|
353
|
+
function expandWeeklyRules(options) {
|
|
354
|
+
const { rules, startDate, endDate, timeZone } = options;
|
|
355
|
+
const startMs = startDate.getTime();
|
|
356
|
+
const endMs = endDate.getTime();
|
|
357
|
+
if (endMs <= startMs) return [];
|
|
358
|
+
const parsedRules = rules.map((rule) => {
|
|
359
|
+
const start = parseTime(rule.startTime);
|
|
360
|
+
const end = parseTime(rule.endTime);
|
|
361
|
+
if (end.hour * 60 + end.minute <= start.hour * 60 + start.minute) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Rule on day ${rule.dayOfWeek} must have endTime strictly after startTime (overnight spans are not supported).`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return { dayOfWeek: rule.dayOfWeek, start, end };
|
|
367
|
+
});
|
|
368
|
+
const firstUtcDayMs = startMs - startMs % DAY_MS;
|
|
369
|
+
const lastUtcDayMs = endMs - endMs % DAY_MS + DAY_MS;
|
|
370
|
+
const windows = [];
|
|
371
|
+
for (let t = firstUtcDayMs; t <= lastUtcDayMs; t += DAY_MS) {
|
|
372
|
+
const wallClock = utcToWallClock(new Date(t), timeZone);
|
|
373
|
+
const weekday = weekdayOf(wallClock);
|
|
374
|
+
for (const rule of parsedRules) {
|
|
375
|
+
if (rule.dayOfWeek !== weekday) continue;
|
|
376
|
+
const windowStart = wallClockToUTC(
|
|
377
|
+
{ ...wallClock, hour: rule.start.hour, minute: rule.start.minute },
|
|
378
|
+
timeZone
|
|
379
|
+
);
|
|
380
|
+
const windowEnd = wallClockToUTC(
|
|
381
|
+
{ ...wallClock, hour: rule.end.hour, minute: rule.end.minute },
|
|
382
|
+
timeZone
|
|
383
|
+
);
|
|
384
|
+
const clippedStart = Math.max(windowStart.getTime(), startMs);
|
|
385
|
+
const clippedEnd = Math.min(windowEnd.getTime(), endMs);
|
|
386
|
+
if (clippedEnd > clippedStart) {
|
|
387
|
+
windows.push({
|
|
388
|
+
start: new Date(clippedStart),
|
|
389
|
+
end: new Date(clippedEnd)
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
windows.sort(
|
|
395
|
+
(a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime()
|
|
396
|
+
);
|
|
397
|
+
const seen = /* @__PURE__ */ new Set();
|
|
398
|
+
const result = [];
|
|
399
|
+
for (const window of windows) {
|
|
400
|
+
const key = `${window.start.getTime()}:${window.end.getTime()}`;
|
|
401
|
+
if (seen.has(key)) continue;
|
|
402
|
+
seen.add(key);
|
|
403
|
+
result.push(window);
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
408
|
+
0 && (module.exports = {
|
|
409
|
+
applyBufferToRange,
|
|
410
|
+
buildMonthMatrix,
|
|
411
|
+
buildMultiMonthMatrix,
|
|
412
|
+
buildMultiWeekMatrix,
|
|
413
|
+
buildWeekMatrix,
|
|
414
|
+
expandWeeklyRules,
|
|
415
|
+
generateTimeSlots,
|
|
416
|
+
rangesOverlap,
|
|
417
|
+
utcToWallClock,
|
|
418
|
+
wallClockToUTC
|
|
419
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
interface TimeRange {
|
|
2
|
+
start: Date;
|
|
3
|
+
end: Date;
|
|
4
|
+
}
|
|
5
|
+
interface BufferConfig {
|
|
6
|
+
beforeMinutes?: number;
|
|
7
|
+
afterMinutes?: number;
|
|
8
|
+
}
|
|
9
|
+
type AvailabilityReason = "AVAILABLE" | "COLLISION" | "OUTSIDE_BOUNDS" | "MIN_NOTICE_VIOLATION" | "MAX_HORIZON_EXCEEDED";
|
|
10
|
+
interface TimeSlot extends TimeRange {
|
|
11
|
+
available: boolean;
|
|
12
|
+
reason: AvailabilityReason;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Represents an active window of available time.
|
|
16
|
+
* Sourced either dynamically from weekly business hours or explicitly from DB shifts.
|
|
17
|
+
*/
|
|
18
|
+
interface AvailableWindow extends TimeRange {
|
|
19
|
+
id?: string;
|
|
20
|
+
providerId?: string;
|
|
21
|
+
}
|
|
22
|
+
interface SlotGeneratorOptions {
|
|
23
|
+
/** Source operating windows (Dynamic business hours OR Explicit DB shifts) */
|
|
24
|
+
windows: AvailableWindow[];
|
|
25
|
+
/** Confirmed bookings, holds, or blackout windows to subtract */
|
|
26
|
+
existingBookings: TimeRange[];
|
|
27
|
+
/** Duration of the appointment in minutes */
|
|
28
|
+
durationMinutes: number;
|
|
29
|
+
/** Step interval between slot start times in minutes (e.g. 15 for slots every 15 mins) */
|
|
30
|
+
intervalMinutes: number;
|
|
31
|
+
/** Required pre/post service buffer rules */
|
|
32
|
+
buffers?: BufferConfig;
|
|
33
|
+
/** Minimum notice required before a booking (in minutes) */
|
|
34
|
+
minNoticeMinutes?: number;
|
|
35
|
+
/** Maximum booking horizon in minutes. Slots starting beyond now + this are excluded. */
|
|
36
|
+
maxHorizonMinutes?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface BookingRepository {
|
|
40
|
+
/** Fetch active windows: supporting both Dynamic rules and Explicit shifts */
|
|
41
|
+
getAvailableWindows(params: {
|
|
42
|
+
startDate: Date;
|
|
43
|
+
endDate: Date;
|
|
44
|
+
providerId?: string;
|
|
45
|
+
mode: "dynamic" | "explicit";
|
|
46
|
+
/** IANA timezone the dynamic weekly rules are expressed in */
|
|
47
|
+
timeZone: string;
|
|
48
|
+
}): Promise<AvailableWindow[]>;
|
|
49
|
+
/** Fetch confirmed bookings and active holds in target window */
|
|
50
|
+
getBookingsInRange(startDate: Date, endDate: Date, providerId?: string): Promise<TimeRange[]>;
|
|
51
|
+
/** Create an atomic slot hold */
|
|
52
|
+
createHold(params: {
|
|
53
|
+
serviceId: string;
|
|
54
|
+
providerId: string;
|
|
55
|
+
range: TimeRange;
|
|
56
|
+
customerId?: string;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
holdToken: string;
|
|
59
|
+
expiresAt: Date;
|
|
60
|
+
}>;
|
|
61
|
+
/** Confirm a hold into a permanent booking */
|
|
62
|
+
confirmBooking(params: {
|
|
63
|
+
holdToken: string;
|
|
64
|
+
customerDetails: Record<string, unknown>;
|
|
65
|
+
}): Promise<{
|
|
66
|
+
bookingId: string;
|
|
67
|
+
}>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Checks if two half-open time ranges [start, end) overlap.
|
|
72
|
+
* Touch points (RangeA.end === RangeB.start) do NOT overlap.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
*
|
|
76
|
+
* @param {TimeRange} a - First range
|
|
77
|
+
* @param {TimeRange} b - Second range
|
|
78
|
+
* @returns - True if the ranges overlap, false otherwise
|
|
79
|
+
*/
|
|
80
|
+
declare function rangesOverlap(a: TimeRange, b: TimeRange): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Expands a target range by applying pre and post buffer padding.
|
|
83
|
+
*/
|
|
84
|
+
declare function applyBufferToRange(range: TimeRange, buffer?: BufferConfig): TimeRange;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
*
|
|
88
|
+
* @param {SlotGeneratorOptions} options
|
|
89
|
+
* @param {Date} now
|
|
90
|
+
* @returns - Generated time slots
|
|
91
|
+
*/
|
|
92
|
+
declare function generateTimeSlots(options: SlotGeneratorOptions, now?: Date): TimeSlot[];
|
|
93
|
+
|
|
94
|
+
interface CalendarDay {
|
|
95
|
+
/** JavaScript Date object representing the day at 00:00:00 local time */
|
|
96
|
+
date: Date;
|
|
97
|
+
/** Local date key formatted as YYYY-MM-DD for easy lookup */
|
|
98
|
+
isoString: string;
|
|
99
|
+
/** Day of the month (1-31) */
|
|
100
|
+
dayOfMonth: number;
|
|
101
|
+
/** Day of the week: 0 = Sunday, 1 = Monday, ..., 6 = Saturday */
|
|
102
|
+
dayOfWeek: number;
|
|
103
|
+
/** Indicates whether the day belongs to the target month being rendered */
|
|
104
|
+
isCurrentMonth: boolean;
|
|
105
|
+
/** Indicates whether this day matches the current local system date */
|
|
106
|
+
isToday: boolean;
|
|
107
|
+
/** Indicates Saturday or Sunday */
|
|
108
|
+
isWeekend: boolean;
|
|
109
|
+
/** Flagged true if outside explicit view boundaries or explicitly blocked */
|
|
110
|
+
isBlocked: boolean;
|
|
111
|
+
/** Reason why the cell is blocked, if applicable */
|
|
112
|
+
blockReason?: "OUT_OF_BOUNDS" | "CUSTOM_EXCLUSION" | "PAST_DATE";
|
|
113
|
+
}
|
|
114
|
+
type MonthGrid = CalendarDay[][];
|
|
115
|
+
type WeekGrid = CalendarDay[];
|
|
116
|
+
interface MonthGridOptions {
|
|
117
|
+
year: number;
|
|
118
|
+
month: number;
|
|
119
|
+
weekStartsOn?: 0 | 1;
|
|
120
|
+
}
|
|
121
|
+
interface WeekGridOptions {
|
|
122
|
+
targetDate: Date;
|
|
123
|
+
weekStartsOn?: 0 | 1;
|
|
124
|
+
}
|
|
125
|
+
interface MultiWeekViewOptions {
|
|
126
|
+
startDate: Date;
|
|
127
|
+
endDate: Date;
|
|
128
|
+
maxWeeksAllowed?: number;
|
|
129
|
+
weekStartsOn?: 0 | 1;
|
|
130
|
+
}
|
|
131
|
+
interface MultiMonthViewOptions {
|
|
132
|
+
startDate: Date;
|
|
133
|
+
endDate: Date;
|
|
134
|
+
maxMonthsAllowed?: number;
|
|
135
|
+
weekStartsOn?: 0 | 1;
|
|
136
|
+
}
|
|
137
|
+
interface MonthViewUnit {
|
|
138
|
+
year: number;
|
|
139
|
+
month: number;
|
|
140
|
+
grid: MonthGrid;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Builds a month grid matrix (6 weeks x 7 days) including leading
|
|
144
|
+
* and trailing padding days from adjacent months.
|
|
145
|
+
*/
|
|
146
|
+
declare function buildMonthMatrix(options: MonthGridOptions): MonthGrid;
|
|
147
|
+
/**
|
|
148
|
+
* Builds a single 7-day array matrix for a specific target week.
|
|
149
|
+
*/
|
|
150
|
+
declare function buildWeekMatrix(options: WeekGridOptions): WeekGrid;
|
|
151
|
+
/**
|
|
152
|
+
* Builds a multi-week matrix across a range of dates.
|
|
153
|
+
* Days outside [startDate, endDate] inside boundary weeks are marked as blocked.
|
|
154
|
+
*/
|
|
155
|
+
declare function buildMultiWeekMatrix(options: MultiWeekViewOptions): MonthGrid;
|
|
156
|
+
/**
|
|
157
|
+
* Builds a multi-month matrix across a date range.
|
|
158
|
+
* Days outside [startDate, endDate] are tagged as blocked cells.
|
|
159
|
+
*/
|
|
160
|
+
declare function buildMultiMonthMatrix(options: MultiMonthViewOptions): MonthViewUnit[];
|
|
161
|
+
|
|
162
|
+
interface WallClockTime {
|
|
163
|
+
year: number;
|
|
164
|
+
month: number;
|
|
165
|
+
day: number;
|
|
166
|
+
hour: number;
|
|
167
|
+
minute: number;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
*
|
|
171
|
+
* @param {WallClockTime} wallClock - Wall clock target
|
|
172
|
+
* @param {string} timeZone - IANA timezone
|
|
173
|
+
* @returns - Exact UTC Date
|
|
174
|
+
*/
|
|
175
|
+
declare const wallClockToUTC: (wallClock: WallClockTime, timeZone: string) => Date;
|
|
176
|
+
/**
|
|
177
|
+
*
|
|
178
|
+
* @param {Date} date - Exact UTC Date
|
|
179
|
+
* @param {string} timeZone - IANA timezone
|
|
180
|
+
* @returns - Wall-clock components in the target timezone
|
|
181
|
+
*/
|
|
182
|
+
declare const utcToWallClock: (date: Date, timeZone: string) => WallClockTime;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Day of the week following JavaScript's Date#getDay() convention:
|
|
186
|
+
* 0 = Sunday, 1 = Monday, ..., 6 = Saturday.
|
|
187
|
+
*/
|
|
188
|
+
type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
189
|
+
/** A single weekly recurring operating rule expressed in local wall-clock time. */
|
|
190
|
+
interface WeeklyRule {
|
|
191
|
+
dayOfWeek: DayOfWeek;
|
|
192
|
+
/** Local wall-clock start, 24-hour "HH:mm" */
|
|
193
|
+
startTime: string;
|
|
194
|
+
/** Local wall-clock end, 24-hour "HH:mm". Must be strictly after startTime. */
|
|
195
|
+
endTime: string;
|
|
196
|
+
}
|
|
197
|
+
interface WeeklyAvailabilityOptions {
|
|
198
|
+
/** Weekly rules to expand into windows */
|
|
199
|
+
rules: WeeklyRule[];
|
|
200
|
+
/** Query range start (inclusive), in UTC */
|
|
201
|
+
startDate: Date;
|
|
202
|
+
/** Query range end (exclusive), in UTC */
|
|
203
|
+
endDate: Date;
|
|
204
|
+
/** IANA timezone the rules are expressed in */
|
|
205
|
+
timeZone: string;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Expands weekly recurring rules into concrete windows within [startDate, endDate].
|
|
209
|
+
*
|
|
210
|
+
* Rules are expressed in local wall-clock time; windows are converted to UTC and
|
|
211
|
+
* clipped to the requested range. DST transitions are handled per-day via Intl,
|
|
212
|
+
* and duplicate windows (e.g. around a fall-back transition) are collapsed.
|
|
213
|
+
*
|
|
214
|
+
* @throws If a rule has an invalid time string or an overnight span (endTime <= startTime).
|
|
215
|
+
*/
|
|
216
|
+
declare function expandWeeklyRules(options: WeeklyAvailabilityOptions): AvailableWindow[];
|
|
217
|
+
|
|
218
|
+
export { applyBufferToRange, buildMonthMatrix, buildMultiMonthMatrix, buildMultiWeekMatrix, buildWeekMatrix, expandWeeklyRules, generateTimeSlots, rangesOverlap, utcToWallClock, wallClockToUTC };
|
|
219
|
+
export type { AvailabilityReason, AvailableWindow, BookingRepository, BufferConfig, CalendarDay, DayOfWeek, MonthGrid, MonthGridOptions, MonthViewUnit, MultiMonthViewOptions, MultiWeekViewOptions, SlotGeneratorOptions, TimeRange, TimeSlot, WallClockTime, WeekGrid, WeekGridOptions, WeeklyAvailabilityOptions, WeeklyRule };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
interface TimeRange {
|
|
2
|
+
start: Date;
|
|
3
|
+
end: Date;
|
|
4
|
+
}
|
|
5
|
+
interface BufferConfig {
|
|
6
|
+
beforeMinutes?: number;
|
|
7
|
+
afterMinutes?: number;
|
|
8
|
+
}
|
|
9
|
+
type AvailabilityReason = "AVAILABLE" | "COLLISION" | "OUTSIDE_BOUNDS" | "MIN_NOTICE_VIOLATION" | "MAX_HORIZON_EXCEEDED";
|
|
10
|
+
interface TimeSlot extends TimeRange {
|
|
11
|
+
available: boolean;
|
|
12
|
+
reason: AvailabilityReason;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Represents an active window of available time.
|
|
16
|
+
* Sourced either dynamically from weekly business hours or explicitly from DB shifts.
|
|
17
|
+
*/
|
|
18
|
+
interface AvailableWindow extends TimeRange {
|
|
19
|
+
id?: string;
|
|
20
|
+
providerId?: string;
|
|
21
|
+
}
|
|
22
|
+
interface SlotGeneratorOptions {
|
|
23
|
+
/** Source operating windows (Dynamic business hours OR Explicit DB shifts) */
|
|
24
|
+
windows: AvailableWindow[];
|
|
25
|
+
/** Confirmed bookings, holds, or blackout windows to subtract */
|
|
26
|
+
existingBookings: TimeRange[];
|
|
27
|
+
/** Duration of the appointment in minutes */
|
|
28
|
+
durationMinutes: number;
|
|
29
|
+
/** Step interval between slot start times in minutes (e.g. 15 for slots every 15 mins) */
|
|
30
|
+
intervalMinutes: number;
|
|
31
|
+
/** Required pre/post service buffer rules */
|
|
32
|
+
buffers?: BufferConfig;
|
|
33
|
+
/** Minimum notice required before a booking (in minutes) */
|
|
34
|
+
minNoticeMinutes?: number;
|
|
35
|
+
/** Maximum booking horizon in minutes. Slots starting beyond now + this are excluded. */
|
|
36
|
+
maxHorizonMinutes?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface BookingRepository {
|
|
40
|
+
/** Fetch active windows: supporting both Dynamic rules and Explicit shifts */
|
|
41
|
+
getAvailableWindows(params: {
|
|
42
|
+
startDate: Date;
|
|
43
|
+
endDate: Date;
|
|
44
|
+
providerId?: string;
|
|
45
|
+
mode: "dynamic" | "explicit";
|
|
46
|
+
/** IANA timezone the dynamic weekly rules are expressed in */
|
|
47
|
+
timeZone: string;
|
|
48
|
+
}): Promise<AvailableWindow[]>;
|
|
49
|
+
/** Fetch confirmed bookings and active holds in target window */
|
|
50
|
+
getBookingsInRange(startDate: Date, endDate: Date, providerId?: string): Promise<TimeRange[]>;
|
|
51
|
+
/** Create an atomic slot hold */
|
|
52
|
+
createHold(params: {
|
|
53
|
+
serviceId: string;
|
|
54
|
+
providerId: string;
|
|
55
|
+
range: TimeRange;
|
|
56
|
+
customerId?: string;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
holdToken: string;
|
|
59
|
+
expiresAt: Date;
|
|
60
|
+
}>;
|
|
61
|
+
/** Confirm a hold into a permanent booking */
|
|
62
|
+
confirmBooking(params: {
|
|
63
|
+
holdToken: string;
|
|
64
|
+
customerDetails: Record<string, unknown>;
|
|
65
|
+
}): Promise<{
|
|
66
|
+
bookingId: string;
|
|
67
|
+
}>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Checks if two half-open time ranges [start, end) overlap.
|
|
72
|
+
* Touch points (RangeA.end === RangeB.start) do NOT overlap.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
*
|
|
76
|
+
* @param {TimeRange} a - First range
|
|
77
|
+
* @param {TimeRange} b - Second range
|
|
78
|
+
* @returns - True if the ranges overlap, false otherwise
|
|
79
|
+
*/
|
|
80
|
+
declare function rangesOverlap(a: TimeRange, b: TimeRange): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Expands a target range by applying pre and post buffer padding.
|
|
83
|
+
*/
|
|
84
|
+
declare function applyBufferToRange(range: TimeRange, buffer?: BufferConfig): TimeRange;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
*
|
|
88
|
+
* @param {SlotGeneratorOptions} options
|
|
89
|
+
* @param {Date} now
|
|
90
|
+
* @returns - Generated time slots
|
|
91
|
+
*/
|
|
92
|
+
declare function generateTimeSlots(options: SlotGeneratorOptions, now?: Date): TimeSlot[];
|
|
93
|
+
|
|
94
|
+
interface CalendarDay {
|
|
95
|
+
/** JavaScript Date object representing the day at 00:00:00 local time */
|
|
96
|
+
date: Date;
|
|
97
|
+
/** Local date key formatted as YYYY-MM-DD for easy lookup */
|
|
98
|
+
isoString: string;
|
|
99
|
+
/** Day of the month (1-31) */
|
|
100
|
+
dayOfMonth: number;
|
|
101
|
+
/** Day of the week: 0 = Sunday, 1 = Monday, ..., 6 = Saturday */
|
|
102
|
+
dayOfWeek: number;
|
|
103
|
+
/** Indicates whether the day belongs to the target month being rendered */
|
|
104
|
+
isCurrentMonth: boolean;
|
|
105
|
+
/** Indicates whether this day matches the current local system date */
|
|
106
|
+
isToday: boolean;
|
|
107
|
+
/** Indicates Saturday or Sunday */
|
|
108
|
+
isWeekend: boolean;
|
|
109
|
+
/** Flagged true if outside explicit view boundaries or explicitly blocked */
|
|
110
|
+
isBlocked: boolean;
|
|
111
|
+
/** Reason why the cell is blocked, if applicable */
|
|
112
|
+
blockReason?: "OUT_OF_BOUNDS" | "CUSTOM_EXCLUSION" | "PAST_DATE";
|
|
113
|
+
}
|
|
114
|
+
type MonthGrid = CalendarDay[][];
|
|
115
|
+
type WeekGrid = CalendarDay[];
|
|
116
|
+
interface MonthGridOptions {
|
|
117
|
+
year: number;
|
|
118
|
+
month: number;
|
|
119
|
+
weekStartsOn?: 0 | 1;
|
|
120
|
+
}
|
|
121
|
+
interface WeekGridOptions {
|
|
122
|
+
targetDate: Date;
|
|
123
|
+
weekStartsOn?: 0 | 1;
|
|
124
|
+
}
|
|
125
|
+
interface MultiWeekViewOptions {
|
|
126
|
+
startDate: Date;
|
|
127
|
+
endDate: Date;
|
|
128
|
+
maxWeeksAllowed?: number;
|
|
129
|
+
weekStartsOn?: 0 | 1;
|
|
130
|
+
}
|
|
131
|
+
interface MultiMonthViewOptions {
|
|
132
|
+
startDate: Date;
|
|
133
|
+
endDate: Date;
|
|
134
|
+
maxMonthsAllowed?: number;
|
|
135
|
+
weekStartsOn?: 0 | 1;
|
|
136
|
+
}
|
|
137
|
+
interface MonthViewUnit {
|
|
138
|
+
year: number;
|
|
139
|
+
month: number;
|
|
140
|
+
grid: MonthGrid;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Builds a month grid matrix (6 weeks x 7 days) including leading
|
|
144
|
+
* and trailing padding days from adjacent months.
|
|
145
|
+
*/
|
|
146
|
+
declare function buildMonthMatrix(options: MonthGridOptions): MonthGrid;
|
|
147
|
+
/**
|
|
148
|
+
* Builds a single 7-day array matrix for a specific target week.
|
|
149
|
+
*/
|
|
150
|
+
declare function buildWeekMatrix(options: WeekGridOptions): WeekGrid;
|
|
151
|
+
/**
|
|
152
|
+
* Builds a multi-week matrix across a range of dates.
|
|
153
|
+
* Days outside [startDate, endDate] inside boundary weeks are marked as blocked.
|
|
154
|
+
*/
|
|
155
|
+
declare function buildMultiWeekMatrix(options: MultiWeekViewOptions): MonthGrid;
|
|
156
|
+
/**
|
|
157
|
+
* Builds a multi-month matrix across a date range.
|
|
158
|
+
* Days outside [startDate, endDate] are tagged as blocked cells.
|
|
159
|
+
*/
|
|
160
|
+
declare function buildMultiMonthMatrix(options: MultiMonthViewOptions): MonthViewUnit[];
|
|
161
|
+
|
|
162
|
+
interface WallClockTime {
|
|
163
|
+
year: number;
|
|
164
|
+
month: number;
|
|
165
|
+
day: number;
|
|
166
|
+
hour: number;
|
|
167
|
+
minute: number;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
*
|
|
171
|
+
* @param {WallClockTime} wallClock - Wall clock target
|
|
172
|
+
* @param {string} timeZone - IANA timezone
|
|
173
|
+
* @returns - Exact UTC Date
|
|
174
|
+
*/
|
|
175
|
+
declare const wallClockToUTC: (wallClock: WallClockTime, timeZone: string) => Date;
|
|
176
|
+
/**
|
|
177
|
+
*
|
|
178
|
+
* @param {Date} date - Exact UTC Date
|
|
179
|
+
* @param {string} timeZone - IANA timezone
|
|
180
|
+
* @returns - Wall-clock components in the target timezone
|
|
181
|
+
*/
|
|
182
|
+
declare const utcToWallClock: (date: Date, timeZone: string) => WallClockTime;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Day of the week following JavaScript's Date#getDay() convention:
|
|
186
|
+
* 0 = Sunday, 1 = Monday, ..., 6 = Saturday.
|
|
187
|
+
*/
|
|
188
|
+
type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
189
|
+
/** A single weekly recurring operating rule expressed in local wall-clock time. */
|
|
190
|
+
interface WeeklyRule {
|
|
191
|
+
dayOfWeek: DayOfWeek;
|
|
192
|
+
/** Local wall-clock start, 24-hour "HH:mm" */
|
|
193
|
+
startTime: string;
|
|
194
|
+
/** Local wall-clock end, 24-hour "HH:mm". Must be strictly after startTime. */
|
|
195
|
+
endTime: string;
|
|
196
|
+
}
|
|
197
|
+
interface WeeklyAvailabilityOptions {
|
|
198
|
+
/** Weekly rules to expand into windows */
|
|
199
|
+
rules: WeeklyRule[];
|
|
200
|
+
/** Query range start (inclusive), in UTC */
|
|
201
|
+
startDate: Date;
|
|
202
|
+
/** Query range end (exclusive), in UTC */
|
|
203
|
+
endDate: Date;
|
|
204
|
+
/** IANA timezone the rules are expressed in */
|
|
205
|
+
timeZone: string;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Expands weekly recurring rules into concrete windows within [startDate, endDate].
|
|
209
|
+
*
|
|
210
|
+
* Rules are expressed in local wall-clock time; windows are converted to UTC and
|
|
211
|
+
* clipped to the requested range. DST transitions are handled per-day via Intl,
|
|
212
|
+
* and duplicate windows (e.g. around a fall-back transition) are collapsed.
|
|
213
|
+
*
|
|
214
|
+
* @throws If a rule has an invalid time string or an overnight span (endTime <= startTime).
|
|
215
|
+
*/
|
|
216
|
+
declare function expandWeeklyRules(options: WeeklyAvailabilityOptions): AvailableWindow[];
|
|
217
|
+
|
|
218
|
+
export { applyBufferToRange, buildMonthMatrix, buildMultiMonthMatrix, buildMultiWeekMatrix, buildWeekMatrix, expandWeeklyRules, generateTimeSlots, rangesOverlap, utcToWallClock, wallClockToUTC };
|
|
219
|
+
export type { AvailabilityReason, AvailableWindow, BookingRepository, BufferConfig, CalendarDay, DayOfWeek, MonthGrid, MonthGridOptions, MonthViewUnit, MultiMonthViewOptions, MultiWeekViewOptions, SlotGeneratorOptions, TimeRange, TimeSlot, WallClockTime, WeekGrid, WeekGridOptions, WeeklyAvailabilityOptions, WeeklyRule };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// src/rules/collisions.ts
|
|
2
|
+
function rangesOverlap(a, b) {
|
|
3
|
+
return a.start < b.end && a.end > b.start;
|
|
4
|
+
}
|
|
5
|
+
function applyBufferToRange(range, buffer) {
|
|
6
|
+
const beforeMs = (buffer?.beforeMinutes ?? 0) * 60 * 1e3;
|
|
7
|
+
const afterMs = (buffer?.afterMinutes ?? 0) * 60 * 1e3;
|
|
8
|
+
return {
|
|
9
|
+
start: new Date(range.start.getTime() - beforeMs),
|
|
10
|
+
end: new Date(range.end.getTime() + afterMs)
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/engine/generator.ts
|
|
15
|
+
function generateTimeSlots(options, now = /* @__PURE__ */ new Date()) {
|
|
16
|
+
const {
|
|
17
|
+
windows,
|
|
18
|
+
existingBookings,
|
|
19
|
+
durationMinutes,
|
|
20
|
+
intervalMinutes,
|
|
21
|
+
buffers = {},
|
|
22
|
+
minNoticeMinutes = 0,
|
|
23
|
+
maxHorizonMinutes
|
|
24
|
+
} = options;
|
|
25
|
+
if (windows.length === 0) return [];
|
|
26
|
+
const slotDurationMs = durationMinutes * 60 * 1e3;
|
|
27
|
+
const stepIntervalMs = intervalMinutes * 60 * 1e3;
|
|
28
|
+
const bufferBeforeMs = (buffers.beforeMinutes ?? 0) * 60 * 1e3;
|
|
29
|
+
const bufferAfterMs = (buffers.afterMinutes ?? 0) * 60 * 1e3;
|
|
30
|
+
const minNoticeCutoff = new Date(
|
|
31
|
+
now.getTime() + minNoticeMinutes * 60 * 1e3
|
|
32
|
+
);
|
|
33
|
+
const maxHorizonCutoff = maxHorizonMinutes !== void 0 ? new Date(now.getTime() + maxHorizonMinutes * 60 * 1e3) : null;
|
|
34
|
+
const bufferedBookings = existingBookings.map(
|
|
35
|
+
(b) => applyBufferToRange(b, buffers)
|
|
36
|
+
);
|
|
37
|
+
const candidateSlots = [];
|
|
38
|
+
for (const window of windows) {
|
|
39
|
+
let currentStartMs = window.start.getTime();
|
|
40
|
+
const windowEndMs = window.end.getTime();
|
|
41
|
+
while (currentStartMs + slotDurationMs <= windowEndMs) {
|
|
42
|
+
const slotStart = new Date(currentStartMs);
|
|
43
|
+
const slotEnd = new Date(currentStartMs + slotDurationMs);
|
|
44
|
+
const bufferedStartMs = currentStartMs - bufferBeforeMs;
|
|
45
|
+
const bufferedEndMs = currentStartMs + slotDurationMs + bufferAfterMs;
|
|
46
|
+
let available = true;
|
|
47
|
+
let reason = "AVAILABLE";
|
|
48
|
+
if (slotStart < minNoticeCutoff) {
|
|
49
|
+
available = false;
|
|
50
|
+
reason = "MIN_NOTICE_VIOLATION";
|
|
51
|
+
}
|
|
52
|
+
if (available && maxHorizonCutoff !== null && slotStart.getTime() > maxHorizonCutoff.getTime()) {
|
|
53
|
+
available = false;
|
|
54
|
+
reason = "MAX_HORIZON_EXCEEDED";
|
|
55
|
+
}
|
|
56
|
+
if (available && (bufferedStartMs < window.start.getTime() || bufferedEndMs > windowEndMs)) {
|
|
57
|
+
available = false;
|
|
58
|
+
reason = "OUTSIDE_BOUNDS";
|
|
59
|
+
}
|
|
60
|
+
if (available) {
|
|
61
|
+
const candidateRange = { start: slotStart, end: slotEnd };
|
|
62
|
+
const bufferedCandidate = applyBufferToRange(candidateRange, buffers);
|
|
63
|
+
const hasCollision = bufferedBookings.some(
|
|
64
|
+
(booking) => rangesOverlap(bufferedCandidate, booking)
|
|
65
|
+
);
|
|
66
|
+
if (hasCollision) {
|
|
67
|
+
available = false;
|
|
68
|
+
reason = "COLLISION";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
candidateSlots.push({
|
|
72
|
+
start: slotStart,
|
|
73
|
+
end: slotEnd,
|
|
74
|
+
available,
|
|
75
|
+
reason
|
|
76
|
+
});
|
|
77
|
+
currentStartMs += stepIntervalMs;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return candidateSlots;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/engine/calendar.ts
|
|
84
|
+
var toDateKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
85
|
+
function buildMonthMatrix(options) {
|
|
86
|
+
const { year, month, weekStartsOn = 0 } = options;
|
|
87
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
88
|
+
const firstDayOfMonth = new Date(year, month - 1, 1);
|
|
89
|
+
let startOffset = firstDayOfMonth.getDay() - weekStartsOn;
|
|
90
|
+
if (startOffset < 0) startOffset += 7;
|
|
91
|
+
const startDate = new Date(firstDayOfMonth);
|
|
92
|
+
startDate.setDate(startDate.getDate() - startOffset);
|
|
93
|
+
const grid = [];
|
|
94
|
+
const currentDate = new Date(startDate);
|
|
95
|
+
for (let week = 0; week < 6; week++) {
|
|
96
|
+
const weekRow = [];
|
|
97
|
+
for (let day = 0; day < 7; day++) {
|
|
98
|
+
const isoString = toDateKey(currentDate);
|
|
99
|
+
const isCurrentMonth = currentDate.getMonth() === month - 1;
|
|
100
|
+
const dayOfWeek = currentDate.getDay();
|
|
101
|
+
weekRow.push({
|
|
102
|
+
date: new Date(currentDate),
|
|
103
|
+
isoString,
|
|
104
|
+
dayOfMonth: currentDate.getDate(),
|
|
105
|
+
dayOfWeek,
|
|
106
|
+
isCurrentMonth,
|
|
107
|
+
isToday: isoString === todayStr,
|
|
108
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
109
|
+
isBlocked: !isCurrentMonth,
|
|
110
|
+
blockReason: !isCurrentMonth ? "OUT_OF_BOUNDS" : void 0
|
|
111
|
+
});
|
|
112
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
113
|
+
}
|
|
114
|
+
grid.push(weekRow);
|
|
115
|
+
}
|
|
116
|
+
return grid;
|
|
117
|
+
}
|
|
118
|
+
function buildWeekMatrix(options) {
|
|
119
|
+
const { targetDate, weekStartsOn = 0 } = options;
|
|
120
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
121
|
+
const startOfWeek = new Date(targetDate);
|
|
122
|
+
let dayOffset = startOfWeek.getDay() - weekStartsOn;
|
|
123
|
+
if (dayOffset < 0) dayOffset += 7;
|
|
124
|
+
startOfWeek.setDate(startOfWeek.getDate() - dayOffset);
|
|
125
|
+
const week = [];
|
|
126
|
+
const currentDate = new Date(startOfWeek);
|
|
127
|
+
for (let i = 0; i < 7; i++) {
|
|
128
|
+
const isoString = toDateKey(currentDate);
|
|
129
|
+
const dayOfWeek = currentDate.getDay();
|
|
130
|
+
const isCurrentMonth = currentDate.getFullYear() === targetDate.getFullYear() && currentDate.getMonth() === targetDate.getMonth();
|
|
131
|
+
week.push({
|
|
132
|
+
date: new Date(currentDate),
|
|
133
|
+
isoString,
|
|
134
|
+
dayOfMonth: currentDate.getDate(),
|
|
135
|
+
dayOfWeek,
|
|
136
|
+
isCurrentMonth,
|
|
137
|
+
isToday: isoString === todayStr,
|
|
138
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
139
|
+
isBlocked: !isCurrentMonth,
|
|
140
|
+
blockReason: !isCurrentMonth ? "OUT_OF_BOUNDS" : void 0
|
|
141
|
+
});
|
|
142
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
143
|
+
}
|
|
144
|
+
return week;
|
|
145
|
+
}
|
|
146
|
+
function buildMultiWeekMatrix(options) {
|
|
147
|
+
const {
|
|
148
|
+
startDate,
|
|
149
|
+
endDate,
|
|
150
|
+
maxWeeksAllowed = 12,
|
|
151
|
+
weekStartsOn = 0
|
|
152
|
+
} = options;
|
|
153
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
154
|
+
const cursor = new Date(startDate);
|
|
155
|
+
let dayOffset = cursor.getDay() - weekStartsOn;
|
|
156
|
+
if (dayOffset < 0) dayOffset += 7;
|
|
157
|
+
cursor.setDate(cursor.getDate() - dayOffset);
|
|
158
|
+
const weeks = [];
|
|
159
|
+
let weekCount = 0;
|
|
160
|
+
const startMs = new Date(
|
|
161
|
+
startDate.getFullYear(),
|
|
162
|
+
startDate.getMonth(),
|
|
163
|
+
startDate.getDate()
|
|
164
|
+
).getTime();
|
|
165
|
+
const endMs = new Date(
|
|
166
|
+
endDate.getFullYear(),
|
|
167
|
+
endDate.getMonth(),
|
|
168
|
+
endDate.getDate()
|
|
169
|
+
).getTime();
|
|
170
|
+
while (cursor.getTime() <= endMs && weekCount < maxWeeksAllowed) {
|
|
171
|
+
const weekRow = [];
|
|
172
|
+
for (let i = 0; i < 7; i++) {
|
|
173
|
+
const currentMs = new Date(
|
|
174
|
+
cursor.getFullYear(),
|
|
175
|
+
cursor.getMonth(),
|
|
176
|
+
cursor.getDate()
|
|
177
|
+
).getTime();
|
|
178
|
+
const isoString = toDateKey(cursor);
|
|
179
|
+
const dayOfWeek = cursor.getDay();
|
|
180
|
+
const isOutOfBounds = currentMs < startMs || currentMs > endMs;
|
|
181
|
+
weekRow.push({
|
|
182
|
+
date: new Date(cursor),
|
|
183
|
+
isoString,
|
|
184
|
+
dayOfMonth: cursor.getDate(),
|
|
185
|
+
dayOfWeek,
|
|
186
|
+
isCurrentMonth: true,
|
|
187
|
+
isToday: isoString === todayStr,
|
|
188
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
189
|
+
isBlocked: isOutOfBounds,
|
|
190
|
+
blockReason: isOutOfBounds ? "OUT_OF_BOUNDS" : void 0
|
|
191
|
+
});
|
|
192
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
193
|
+
}
|
|
194
|
+
weeks.push(weekRow);
|
|
195
|
+
weekCount++;
|
|
196
|
+
}
|
|
197
|
+
return weeks;
|
|
198
|
+
}
|
|
199
|
+
function buildMultiMonthMatrix(options) {
|
|
200
|
+
const {
|
|
201
|
+
startDate,
|
|
202
|
+
endDate,
|
|
203
|
+
maxMonthsAllowed = 12,
|
|
204
|
+
weekStartsOn = 0
|
|
205
|
+
} = options;
|
|
206
|
+
const startMs = new Date(
|
|
207
|
+
startDate.getFullYear(),
|
|
208
|
+
startDate.getMonth(),
|
|
209
|
+
startDate.getDate()
|
|
210
|
+
).getTime();
|
|
211
|
+
const endMs = new Date(
|
|
212
|
+
endDate.getFullYear(),
|
|
213
|
+
endDate.getMonth(),
|
|
214
|
+
endDate.getDate()
|
|
215
|
+
).getTime();
|
|
216
|
+
const months = [];
|
|
217
|
+
let currentYear = startDate.getFullYear();
|
|
218
|
+
let currentMonth = startDate.getMonth() + 1;
|
|
219
|
+
let monthCount = 0;
|
|
220
|
+
const targetEndYear = endDate.getFullYear();
|
|
221
|
+
const targetEndMonth = endDate.getMonth() + 1;
|
|
222
|
+
while ((currentYear < targetEndYear || currentYear === targetEndYear && currentMonth <= targetEndMonth) && monthCount < maxMonthsAllowed) {
|
|
223
|
+
const baseGrid = buildMonthMatrix({
|
|
224
|
+
year: currentYear,
|
|
225
|
+
month: currentMonth,
|
|
226
|
+
weekStartsOn
|
|
227
|
+
});
|
|
228
|
+
const scopedGrid = baseGrid.map(
|
|
229
|
+
(weekRow) => weekRow.map((cell) => {
|
|
230
|
+
const cellMs = new Date(
|
|
231
|
+
cell.date.getFullYear(),
|
|
232
|
+
cell.date.getMonth(),
|
|
233
|
+
cell.date.getDate()
|
|
234
|
+
).getTime();
|
|
235
|
+
const isOutOfBounds = cellMs < startMs || cellMs > endMs || !cell.isCurrentMonth;
|
|
236
|
+
return {
|
|
237
|
+
...cell,
|
|
238
|
+
isBlocked: isOutOfBounds,
|
|
239
|
+
blockReason: isOutOfBounds ? "OUT_OF_BOUNDS" : void 0
|
|
240
|
+
};
|
|
241
|
+
})
|
|
242
|
+
);
|
|
243
|
+
months.push({
|
|
244
|
+
year: currentYear,
|
|
245
|
+
month: currentMonth,
|
|
246
|
+
grid: scopedGrid
|
|
247
|
+
});
|
|
248
|
+
monthCount++;
|
|
249
|
+
currentMonth++;
|
|
250
|
+
if (currentMonth > 12) {
|
|
251
|
+
currentMonth = 1;
|
|
252
|
+
currentYear++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return months;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/engine/timezone.ts
|
|
259
|
+
var wallClockToUTC = (wallClock, timeZone) => {
|
|
260
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
261
|
+
const isoStr = `${wallClock.year}-${pad(wallClock.month)}-${pad(wallClock.day)}T${pad(wallClock.hour)}:${pad(wallClock.minute)}:00`;
|
|
262
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
263
|
+
timeZone,
|
|
264
|
+
year: "numeric",
|
|
265
|
+
month: "2-digit",
|
|
266
|
+
day: "2-digit",
|
|
267
|
+
hour: "2-digit",
|
|
268
|
+
minute: "2-digit",
|
|
269
|
+
second: "2-digit",
|
|
270
|
+
hour12: false
|
|
271
|
+
});
|
|
272
|
+
const utcGuess = /* @__PURE__ */ new Date(`${isoStr}Z`);
|
|
273
|
+
const parts = Object.fromEntries(
|
|
274
|
+
formatter.formatToParts(utcGuess).map((p) => [p.type, p.value])
|
|
275
|
+
);
|
|
276
|
+
const localHeader = `${parts.year}-${parts.month}-${parts.day}T${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}:${parts.second}`;
|
|
277
|
+
const diffMs = (/* @__PURE__ */ new Date(`${isoStr}Z`)).getTime() - (/* @__PURE__ */ new Date(`${localHeader}Z`)).getTime();
|
|
278
|
+
return new Date(utcGuess.getTime() + diffMs);
|
|
279
|
+
};
|
|
280
|
+
var utcToWallClock = (date, timeZone) => {
|
|
281
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
282
|
+
timeZone,
|
|
283
|
+
year: "numeric",
|
|
284
|
+
month: "2-digit",
|
|
285
|
+
day: "2-digit",
|
|
286
|
+
hour: "2-digit",
|
|
287
|
+
minute: "2-digit",
|
|
288
|
+
second: "2-digit",
|
|
289
|
+
hour12: false
|
|
290
|
+
});
|
|
291
|
+
const parts = Object.fromEntries(
|
|
292
|
+
formatter.formatToParts(date).map((p) => [p.type, p.value])
|
|
293
|
+
);
|
|
294
|
+
return {
|
|
295
|
+
year: Number(parts.year),
|
|
296
|
+
month: Number(parts.month),
|
|
297
|
+
day: Number(parts.day),
|
|
298
|
+
hour: parts.hour === "24" ? 0 : Number(parts.hour),
|
|
299
|
+
minute: Number(parts.minute)
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// src/engine/weekly.ts
|
|
304
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
305
|
+
var parseTime = (time) => {
|
|
306
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
|
307
|
+
if (!match) {
|
|
308
|
+
throw new Error(`Invalid time "${time}" \u2014 expected 24-hour "HH:mm".`);
|
|
309
|
+
}
|
|
310
|
+
const hour = Number(match[1]);
|
|
311
|
+
const minute = Number(match[2]);
|
|
312
|
+
if (hour > 23 || minute > 59) {
|
|
313
|
+
throw new Error(`Invalid time "${time}" \u2014 hour/minute out of range.`);
|
|
314
|
+
}
|
|
315
|
+
return { hour, minute };
|
|
316
|
+
};
|
|
317
|
+
var weekdayOf = (wallClock) => new Date(wallClock.year, wallClock.month - 1, wallClock.day).getDay();
|
|
318
|
+
function expandWeeklyRules(options) {
|
|
319
|
+
const { rules, startDate, endDate, timeZone } = options;
|
|
320
|
+
const startMs = startDate.getTime();
|
|
321
|
+
const endMs = endDate.getTime();
|
|
322
|
+
if (endMs <= startMs) return [];
|
|
323
|
+
const parsedRules = rules.map((rule) => {
|
|
324
|
+
const start = parseTime(rule.startTime);
|
|
325
|
+
const end = parseTime(rule.endTime);
|
|
326
|
+
if (end.hour * 60 + end.minute <= start.hour * 60 + start.minute) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Rule on day ${rule.dayOfWeek} must have endTime strictly after startTime (overnight spans are not supported).`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return { dayOfWeek: rule.dayOfWeek, start, end };
|
|
332
|
+
});
|
|
333
|
+
const firstUtcDayMs = startMs - startMs % DAY_MS;
|
|
334
|
+
const lastUtcDayMs = endMs - endMs % DAY_MS + DAY_MS;
|
|
335
|
+
const windows = [];
|
|
336
|
+
for (let t = firstUtcDayMs; t <= lastUtcDayMs; t += DAY_MS) {
|
|
337
|
+
const wallClock = utcToWallClock(new Date(t), timeZone);
|
|
338
|
+
const weekday = weekdayOf(wallClock);
|
|
339
|
+
for (const rule of parsedRules) {
|
|
340
|
+
if (rule.dayOfWeek !== weekday) continue;
|
|
341
|
+
const windowStart = wallClockToUTC(
|
|
342
|
+
{ ...wallClock, hour: rule.start.hour, minute: rule.start.minute },
|
|
343
|
+
timeZone
|
|
344
|
+
);
|
|
345
|
+
const windowEnd = wallClockToUTC(
|
|
346
|
+
{ ...wallClock, hour: rule.end.hour, minute: rule.end.minute },
|
|
347
|
+
timeZone
|
|
348
|
+
);
|
|
349
|
+
const clippedStart = Math.max(windowStart.getTime(), startMs);
|
|
350
|
+
const clippedEnd = Math.min(windowEnd.getTime(), endMs);
|
|
351
|
+
if (clippedEnd > clippedStart) {
|
|
352
|
+
windows.push({
|
|
353
|
+
start: new Date(clippedStart),
|
|
354
|
+
end: new Date(clippedEnd)
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
windows.sort(
|
|
360
|
+
(a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime()
|
|
361
|
+
);
|
|
362
|
+
const seen = /* @__PURE__ */ new Set();
|
|
363
|
+
const result = [];
|
|
364
|
+
for (const window of windows) {
|
|
365
|
+
const key = `${window.start.getTime()}:${window.end.getTime()}`;
|
|
366
|
+
if (seen.has(key)) continue;
|
|
367
|
+
seen.add(key);
|
|
368
|
+
result.push(window);
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
export {
|
|
373
|
+
applyBufferToRange,
|
|
374
|
+
buildMonthMatrix,
|
|
375
|
+
buildMultiMonthMatrix,
|
|
376
|
+
buildMultiWeekMatrix,
|
|
377
|
+
buildWeekMatrix,
|
|
378
|
+
expandWeeklyRules,
|
|
379
|
+
generateTimeSlots,
|
|
380
|
+
rangesOverlap,
|
|
381
|
+
utcToWallClock,
|
|
382
|
+
wallClockToUTC
|
|
383
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apiary-dev/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Apiary core: zero-dependency, framework-agnostic scheduling domain logic",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/index.d.cts",
|
|
18
|
+
"default": "./dist/index.cjs"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"tsup": "^9.0.0",
|
|
27
|
+
"typescript": "^6.0.3"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
31
|
+
"test": "vitest"
|
|
32
|
+
}
|
|
6
33
|
}
|