@apiary-dev/core 0.0.0-stage → 0.1.1
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 +437 -0
- package/dist/index.d.cts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +399 -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,437 @@
|
|
|
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
|
+
BookingConflictError: () => BookingConflictError,
|
|
24
|
+
ValidationError: () => ValidationError,
|
|
25
|
+
applyBufferToRange: () => applyBufferToRange,
|
|
26
|
+
buildMonthMatrix: () => buildMonthMatrix,
|
|
27
|
+
buildMultiMonthMatrix: () => buildMultiMonthMatrix,
|
|
28
|
+
buildMultiWeekMatrix: () => buildMultiWeekMatrix,
|
|
29
|
+
buildWeekMatrix: () => buildWeekMatrix,
|
|
30
|
+
expandWeeklyRules: () => expandWeeklyRules,
|
|
31
|
+
generateTimeSlots: () => generateTimeSlots,
|
|
32
|
+
rangesOverlap: () => rangesOverlap,
|
|
33
|
+
utcToWallClock: () => utcToWallClock,
|
|
34
|
+
wallClockToUTC: () => wallClockToUTC
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
|
|
38
|
+
// src/errors.ts
|
|
39
|
+
var BookingConflictError = class extends Error {
|
|
40
|
+
constructor(message = "Requested slot overlaps an existing booking or hold") {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "BookingConflictError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var ValidationError = class extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "ValidationError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/rules/collisions.ts
|
|
53
|
+
function rangesOverlap(a, b) {
|
|
54
|
+
return a.start < b.end && a.end > b.start;
|
|
55
|
+
}
|
|
56
|
+
function applyBufferToRange(range, buffer) {
|
|
57
|
+
const beforeMs = (buffer?.beforeMinutes ?? 0) * 60 * 1e3;
|
|
58
|
+
const afterMs = (buffer?.afterMinutes ?? 0) * 60 * 1e3;
|
|
59
|
+
return {
|
|
60
|
+
start: new Date(range.start.getTime() - beforeMs),
|
|
61
|
+
end: new Date(range.end.getTime() + afterMs)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/engine/generator.ts
|
|
66
|
+
function generateTimeSlots(options, now = /* @__PURE__ */ new Date()) {
|
|
67
|
+
const {
|
|
68
|
+
windows,
|
|
69
|
+
existingBookings,
|
|
70
|
+
durationMinutes,
|
|
71
|
+
intervalMinutes,
|
|
72
|
+
buffers = {},
|
|
73
|
+
minNoticeMinutes = 0,
|
|
74
|
+
maxHorizonMinutes
|
|
75
|
+
} = options;
|
|
76
|
+
if (windows.length === 0) return [];
|
|
77
|
+
const slotDurationMs = durationMinutes * 60 * 1e3;
|
|
78
|
+
const stepIntervalMs = intervalMinutes * 60 * 1e3;
|
|
79
|
+
const bufferBeforeMs = (buffers.beforeMinutes ?? 0) * 60 * 1e3;
|
|
80
|
+
const bufferAfterMs = (buffers.afterMinutes ?? 0) * 60 * 1e3;
|
|
81
|
+
const minNoticeCutoff = new Date(
|
|
82
|
+
now.getTime() + minNoticeMinutes * 60 * 1e3
|
|
83
|
+
);
|
|
84
|
+
const maxHorizonCutoff = maxHorizonMinutes !== void 0 ? new Date(now.getTime() + maxHorizonMinutes * 60 * 1e3) : null;
|
|
85
|
+
const bufferedBookings = existingBookings.map(
|
|
86
|
+
(b) => applyBufferToRange(b, buffers)
|
|
87
|
+
);
|
|
88
|
+
const candidateSlots = [];
|
|
89
|
+
for (const window of windows) {
|
|
90
|
+
let currentStartMs = window.start.getTime();
|
|
91
|
+
const windowEndMs = window.end.getTime();
|
|
92
|
+
while (currentStartMs + slotDurationMs <= windowEndMs) {
|
|
93
|
+
const slotStart = new Date(currentStartMs);
|
|
94
|
+
const slotEnd = new Date(currentStartMs + slotDurationMs);
|
|
95
|
+
const bufferedStartMs = currentStartMs - bufferBeforeMs;
|
|
96
|
+
const bufferedEndMs = currentStartMs + slotDurationMs + bufferAfterMs;
|
|
97
|
+
let available = true;
|
|
98
|
+
let reason = "AVAILABLE";
|
|
99
|
+
if (slotStart < minNoticeCutoff) {
|
|
100
|
+
available = false;
|
|
101
|
+
reason = "MIN_NOTICE_VIOLATION";
|
|
102
|
+
}
|
|
103
|
+
if (available && maxHorizonCutoff !== null && slotStart.getTime() > maxHorizonCutoff.getTime()) {
|
|
104
|
+
available = false;
|
|
105
|
+
reason = "MAX_HORIZON_EXCEEDED";
|
|
106
|
+
}
|
|
107
|
+
if (available && (bufferedStartMs < window.start.getTime() || bufferedEndMs > windowEndMs)) {
|
|
108
|
+
available = false;
|
|
109
|
+
reason = "OUTSIDE_BOUNDS";
|
|
110
|
+
}
|
|
111
|
+
if (available) {
|
|
112
|
+
const candidateRange = { start: slotStart, end: slotEnd };
|
|
113
|
+
const bufferedCandidate = applyBufferToRange(candidateRange, buffers);
|
|
114
|
+
const hasCollision = bufferedBookings.some(
|
|
115
|
+
(booking) => rangesOverlap(bufferedCandidate, booking)
|
|
116
|
+
);
|
|
117
|
+
if (hasCollision) {
|
|
118
|
+
available = false;
|
|
119
|
+
reason = "COLLISION";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
candidateSlots.push({
|
|
123
|
+
start: slotStart,
|
|
124
|
+
end: slotEnd,
|
|
125
|
+
available,
|
|
126
|
+
reason
|
|
127
|
+
});
|
|
128
|
+
currentStartMs += stepIntervalMs;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return candidateSlots;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/engine/calendar.ts
|
|
135
|
+
var toDateKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
136
|
+
function buildMonthMatrix(options) {
|
|
137
|
+
const { year, month, weekStartsOn = 0 } = options;
|
|
138
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
139
|
+
const firstDayOfMonth = new Date(year, month - 1, 1);
|
|
140
|
+
let startOffset = firstDayOfMonth.getDay() - weekStartsOn;
|
|
141
|
+
if (startOffset < 0) startOffset += 7;
|
|
142
|
+
const startDate = new Date(firstDayOfMonth);
|
|
143
|
+
startDate.setDate(startDate.getDate() - startOffset);
|
|
144
|
+
const grid = [];
|
|
145
|
+
const currentDate = new Date(startDate);
|
|
146
|
+
for (let week = 0; week < 6; week++) {
|
|
147
|
+
const weekRow = [];
|
|
148
|
+
for (let day = 0; day < 7; day++) {
|
|
149
|
+
const isoString = toDateKey(currentDate);
|
|
150
|
+
const isCurrentMonth = currentDate.getMonth() === month - 1;
|
|
151
|
+
const dayOfWeek = currentDate.getDay();
|
|
152
|
+
weekRow.push({
|
|
153
|
+
date: new Date(currentDate),
|
|
154
|
+
isoString,
|
|
155
|
+
dayOfMonth: currentDate.getDate(),
|
|
156
|
+
dayOfWeek,
|
|
157
|
+
isCurrentMonth,
|
|
158
|
+
isToday: isoString === todayStr,
|
|
159
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
160
|
+
isBlocked: !isCurrentMonth,
|
|
161
|
+
blockReason: !isCurrentMonth ? "OUT_OF_BOUNDS" : void 0
|
|
162
|
+
});
|
|
163
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
164
|
+
}
|
|
165
|
+
grid.push(weekRow);
|
|
166
|
+
}
|
|
167
|
+
return grid;
|
|
168
|
+
}
|
|
169
|
+
function buildWeekMatrix(options) {
|
|
170
|
+
const { targetDate, weekStartsOn = 0 } = options;
|
|
171
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
172
|
+
const startOfWeek = new Date(targetDate);
|
|
173
|
+
let dayOffset = startOfWeek.getDay() - weekStartsOn;
|
|
174
|
+
if (dayOffset < 0) dayOffset += 7;
|
|
175
|
+
startOfWeek.setDate(startOfWeek.getDate() - dayOffset);
|
|
176
|
+
const week = [];
|
|
177
|
+
const currentDate = new Date(startOfWeek);
|
|
178
|
+
for (let i = 0; i < 7; i++) {
|
|
179
|
+
const isoString = toDateKey(currentDate);
|
|
180
|
+
const dayOfWeek = currentDate.getDay();
|
|
181
|
+
const isCurrentMonth = currentDate.getFullYear() === targetDate.getFullYear() && currentDate.getMonth() === targetDate.getMonth();
|
|
182
|
+
week.push({
|
|
183
|
+
date: new Date(currentDate),
|
|
184
|
+
isoString,
|
|
185
|
+
dayOfMonth: currentDate.getDate(),
|
|
186
|
+
dayOfWeek,
|
|
187
|
+
isCurrentMonth,
|
|
188
|
+
isToday: isoString === todayStr,
|
|
189
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
190
|
+
isBlocked: !isCurrentMonth,
|
|
191
|
+
blockReason: !isCurrentMonth ? "OUT_OF_BOUNDS" : void 0
|
|
192
|
+
});
|
|
193
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
194
|
+
}
|
|
195
|
+
return week;
|
|
196
|
+
}
|
|
197
|
+
function buildMultiWeekMatrix(options) {
|
|
198
|
+
const {
|
|
199
|
+
startDate,
|
|
200
|
+
endDate,
|
|
201
|
+
maxWeeksAllowed = 12,
|
|
202
|
+
weekStartsOn = 0
|
|
203
|
+
} = options;
|
|
204
|
+
const todayStr = toDateKey(/* @__PURE__ */ new Date());
|
|
205
|
+
const cursor = new Date(startDate);
|
|
206
|
+
let dayOffset = cursor.getDay() - weekStartsOn;
|
|
207
|
+
if (dayOffset < 0) dayOffset += 7;
|
|
208
|
+
cursor.setDate(cursor.getDate() - dayOffset);
|
|
209
|
+
const weeks = [];
|
|
210
|
+
let weekCount = 0;
|
|
211
|
+
const startMs = new Date(
|
|
212
|
+
startDate.getFullYear(),
|
|
213
|
+
startDate.getMonth(),
|
|
214
|
+
startDate.getDate()
|
|
215
|
+
).getTime();
|
|
216
|
+
const endMs = new Date(
|
|
217
|
+
endDate.getFullYear(),
|
|
218
|
+
endDate.getMonth(),
|
|
219
|
+
endDate.getDate()
|
|
220
|
+
).getTime();
|
|
221
|
+
while (cursor.getTime() <= endMs && weekCount < maxWeeksAllowed) {
|
|
222
|
+
const weekRow = [];
|
|
223
|
+
for (let i = 0; i < 7; i++) {
|
|
224
|
+
const currentMs = new Date(
|
|
225
|
+
cursor.getFullYear(),
|
|
226
|
+
cursor.getMonth(),
|
|
227
|
+
cursor.getDate()
|
|
228
|
+
).getTime();
|
|
229
|
+
const isoString = toDateKey(cursor);
|
|
230
|
+
const dayOfWeek = cursor.getDay();
|
|
231
|
+
const isOutOfBounds = currentMs < startMs || currentMs > endMs;
|
|
232
|
+
weekRow.push({
|
|
233
|
+
date: new Date(cursor),
|
|
234
|
+
isoString,
|
|
235
|
+
dayOfMonth: cursor.getDate(),
|
|
236
|
+
dayOfWeek,
|
|
237
|
+
isCurrentMonth: true,
|
|
238
|
+
isToday: isoString === todayStr,
|
|
239
|
+
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
|
240
|
+
isBlocked: isOutOfBounds,
|
|
241
|
+
blockReason: isOutOfBounds ? "OUT_OF_BOUNDS" : void 0
|
|
242
|
+
});
|
|
243
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
244
|
+
}
|
|
245
|
+
weeks.push(weekRow);
|
|
246
|
+
weekCount++;
|
|
247
|
+
}
|
|
248
|
+
return weeks;
|
|
249
|
+
}
|
|
250
|
+
function buildMultiMonthMatrix(options) {
|
|
251
|
+
const {
|
|
252
|
+
startDate,
|
|
253
|
+
endDate,
|
|
254
|
+
maxMonthsAllowed = 12,
|
|
255
|
+
weekStartsOn = 0
|
|
256
|
+
} = options;
|
|
257
|
+
const startMs = new Date(
|
|
258
|
+
startDate.getFullYear(),
|
|
259
|
+
startDate.getMonth(),
|
|
260
|
+
startDate.getDate()
|
|
261
|
+
).getTime();
|
|
262
|
+
const endMs = new Date(
|
|
263
|
+
endDate.getFullYear(),
|
|
264
|
+
endDate.getMonth(),
|
|
265
|
+
endDate.getDate()
|
|
266
|
+
).getTime();
|
|
267
|
+
const months = [];
|
|
268
|
+
let currentYear = startDate.getFullYear();
|
|
269
|
+
let currentMonth = startDate.getMonth() + 1;
|
|
270
|
+
let monthCount = 0;
|
|
271
|
+
const targetEndYear = endDate.getFullYear();
|
|
272
|
+
const targetEndMonth = endDate.getMonth() + 1;
|
|
273
|
+
while ((currentYear < targetEndYear || currentYear === targetEndYear && currentMonth <= targetEndMonth) && monthCount < maxMonthsAllowed) {
|
|
274
|
+
const baseGrid = buildMonthMatrix({
|
|
275
|
+
year: currentYear,
|
|
276
|
+
month: currentMonth,
|
|
277
|
+
weekStartsOn
|
|
278
|
+
});
|
|
279
|
+
const scopedGrid = baseGrid.map(
|
|
280
|
+
(weekRow) => weekRow.map((cell) => {
|
|
281
|
+
const cellMs = new Date(
|
|
282
|
+
cell.date.getFullYear(),
|
|
283
|
+
cell.date.getMonth(),
|
|
284
|
+
cell.date.getDate()
|
|
285
|
+
).getTime();
|
|
286
|
+
const isOutOfBounds = cellMs < startMs || cellMs > endMs || !cell.isCurrentMonth;
|
|
287
|
+
return {
|
|
288
|
+
...cell,
|
|
289
|
+
isBlocked: isOutOfBounds,
|
|
290
|
+
blockReason: isOutOfBounds ? "OUT_OF_BOUNDS" : void 0
|
|
291
|
+
};
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
months.push({
|
|
295
|
+
year: currentYear,
|
|
296
|
+
month: currentMonth,
|
|
297
|
+
grid: scopedGrid
|
|
298
|
+
});
|
|
299
|
+
monthCount++;
|
|
300
|
+
currentMonth++;
|
|
301
|
+
if (currentMonth > 12) {
|
|
302
|
+
currentMonth = 1;
|
|
303
|
+
currentYear++;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return months;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/engine/timezone.ts
|
|
310
|
+
var wallClockToUTC = (wallClock, timeZone) => {
|
|
311
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
312
|
+
const isoStr = `${wallClock.year}-${pad(wallClock.month)}-${pad(wallClock.day)}T${pad(wallClock.hour)}:${pad(wallClock.minute)}:00`;
|
|
313
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
314
|
+
timeZone,
|
|
315
|
+
year: "numeric",
|
|
316
|
+
month: "2-digit",
|
|
317
|
+
day: "2-digit",
|
|
318
|
+
hour: "2-digit",
|
|
319
|
+
minute: "2-digit",
|
|
320
|
+
second: "2-digit",
|
|
321
|
+
hour12: false
|
|
322
|
+
});
|
|
323
|
+
const utcGuess = /* @__PURE__ */ new Date(`${isoStr}Z`);
|
|
324
|
+
const parts = Object.fromEntries(
|
|
325
|
+
formatter.formatToParts(utcGuess).map((p) => [p.type, p.value])
|
|
326
|
+
);
|
|
327
|
+
const localHeader = `${parts.year}-${parts.month}-${parts.day}T${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}:${parts.second}`;
|
|
328
|
+
const diffMs = (/* @__PURE__ */ new Date(`${isoStr}Z`)).getTime() - (/* @__PURE__ */ new Date(`${localHeader}Z`)).getTime();
|
|
329
|
+
return new Date(utcGuess.getTime() + diffMs);
|
|
330
|
+
};
|
|
331
|
+
var utcToWallClock = (date, timeZone) => {
|
|
332
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
333
|
+
timeZone,
|
|
334
|
+
year: "numeric",
|
|
335
|
+
month: "2-digit",
|
|
336
|
+
day: "2-digit",
|
|
337
|
+
hour: "2-digit",
|
|
338
|
+
minute: "2-digit",
|
|
339
|
+
second: "2-digit",
|
|
340
|
+
hour12: false
|
|
341
|
+
});
|
|
342
|
+
const parts = Object.fromEntries(
|
|
343
|
+
formatter.formatToParts(date).map((p) => [p.type, p.value])
|
|
344
|
+
);
|
|
345
|
+
return {
|
|
346
|
+
year: Number(parts.year),
|
|
347
|
+
month: Number(parts.month),
|
|
348
|
+
day: Number(parts.day),
|
|
349
|
+
hour: parts.hour === "24" ? 0 : Number(parts.hour),
|
|
350
|
+
minute: Number(parts.minute)
|
|
351
|
+
};
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/engine/weekly.ts
|
|
355
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
356
|
+
var parseTime = (time) => {
|
|
357
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
|
358
|
+
if (!match) {
|
|
359
|
+
throw new Error(`Invalid time "${time}" \u2014 expected 24-hour "HH:mm".`);
|
|
360
|
+
}
|
|
361
|
+
const hour = Number(match[1]);
|
|
362
|
+
const minute = Number(match[2]);
|
|
363
|
+
if (hour > 23 || minute > 59) {
|
|
364
|
+
throw new Error(`Invalid time "${time}" \u2014 hour/minute out of range.`);
|
|
365
|
+
}
|
|
366
|
+
return { hour, minute };
|
|
367
|
+
};
|
|
368
|
+
var weekdayOf = (wallClock) => new Date(wallClock.year, wallClock.month - 1, wallClock.day).getDay();
|
|
369
|
+
function expandWeeklyRules(options) {
|
|
370
|
+
const { rules, startDate, endDate, timeZone } = options;
|
|
371
|
+
const startMs = startDate.getTime();
|
|
372
|
+
const endMs = endDate.getTime();
|
|
373
|
+
if (endMs <= startMs) return [];
|
|
374
|
+
const parsedRules = rules.map((rule) => {
|
|
375
|
+
const start = parseTime(rule.startTime);
|
|
376
|
+
const end = parseTime(rule.endTime);
|
|
377
|
+
if (end.hour * 60 + end.minute <= start.hour * 60 + start.minute) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`Rule on day ${rule.dayOfWeek} must have endTime strictly after startTime (overnight spans are not supported).`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return { dayOfWeek: rule.dayOfWeek, start, end };
|
|
383
|
+
});
|
|
384
|
+
const firstUtcDayMs = startMs - startMs % DAY_MS;
|
|
385
|
+
const lastUtcDayMs = endMs - endMs % DAY_MS + DAY_MS;
|
|
386
|
+
const windows = [];
|
|
387
|
+
for (let t = firstUtcDayMs; t <= lastUtcDayMs; t += DAY_MS) {
|
|
388
|
+
const wallClock = utcToWallClock(new Date(t), timeZone);
|
|
389
|
+
const weekday = weekdayOf(wallClock);
|
|
390
|
+
for (const rule of parsedRules) {
|
|
391
|
+
if (rule.dayOfWeek !== weekday) continue;
|
|
392
|
+
const windowStart = wallClockToUTC(
|
|
393
|
+
{ ...wallClock, hour: rule.start.hour, minute: rule.start.minute },
|
|
394
|
+
timeZone
|
|
395
|
+
);
|
|
396
|
+
const windowEnd = wallClockToUTC(
|
|
397
|
+
{ ...wallClock, hour: rule.end.hour, minute: rule.end.minute },
|
|
398
|
+
timeZone
|
|
399
|
+
);
|
|
400
|
+
const clippedStart = Math.max(windowStart.getTime(), startMs);
|
|
401
|
+
const clippedEnd = Math.min(windowEnd.getTime(), endMs);
|
|
402
|
+
if (clippedEnd > clippedStart) {
|
|
403
|
+
windows.push({
|
|
404
|
+
start: new Date(clippedStart),
|
|
405
|
+
end: new Date(clippedEnd)
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
windows.sort(
|
|
411
|
+
(a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime()
|
|
412
|
+
);
|
|
413
|
+
const seen = /* @__PURE__ */ new Set();
|
|
414
|
+
const result = [];
|
|
415
|
+
for (const window of windows) {
|
|
416
|
+
const key = `${window.start.getTime()}:${window.end.getTime()}`;
|
|
417
|
+
if (seen.has(key)) continue;
|
|
418
|
+
seen.add(key);
|
|
419
|
+
result.push(window);
|
|
420
|
+
}
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
424
|
+
0 && (module.exports = {
|
|
425
|
+
BookingConflictError,
|
|
426
|
+
ValidationError,
|
|
427
|
+
applyBufferToRange,
|
|
428
|
+
buildMonthMatrix,
|
|
429
|
+
buildMultiMonthMatrix,
|
|
430
|
+
buildMultiWeekMatrix,
|
|
431
|
+
buildWeekMatrix,
|
|
432
|
+
expandWeeklyRules,
|
|
433
|
+
generateTimeSlots,
|
|
434
|
+
rangesOverlap,
|
|
435
|
+
utcToWallClock,
|
|
436
|
+
wallClockToUTC
|
|
437
|
+
});
|