@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 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,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
+ });