@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 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
- # Temporary Holding Version
1
+ # @apiary-dev/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ });
@@ -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 };
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }