scheduling-sdk
Version:
Brought to you by Recal - A TypeScript SDK for scheduling functionality
506 lines (493 loc) • 17.2 kB
JavaScript
// src/types/availability.types.ts
var workDays = ["monday", "tuesday", "wednesday", "thursday", "friday"];
var weekendDays = ["saturday", "sunday"];
// src/validators/time-range.validator.ts
function validateTimeRange(startTime, endTime) {
if (!(startTime instanceof Date) || isNaN(startTime.getTime())) {
throw new Error("Start time must be a valid Date");
}
if (!(endTime instanceof Date) || isNaN(endTime.getTime())) {
throw new Error("End time must be a valid Date");
}
if (startTime.getTime() >= endTime.getTime()) {
throw new Error("Start time must be before end time");
}
}
// src/validators/options.validator.ts
function validateOptions(options) {
validateDuration(options.slotDuration);
if (options.padding !== undefined) {
validatePadding(options.padding);
}
if (options.slotSplit !== undefined) {
validateSplit(options.slotSplit);
}
if (options.offset !== undefined) {
validateOffset(options.offset);
}
}
function validateDuration(duration) {
if (typeof duration !== "number" || duration <= 0 || !Number.isFinite(duration)) {
throw new Error("Slot duration must be a positive number");
}
}
function validateSplit(split) {
if (typeof split !== "number" || split <= 0 || !Number.isFinite(split)) {
throw new Error("Slot split must be a positive number");
}
}
function validateOffset(offset) {
if (typeof offset !== "number" || offset < 0 || !Number.isFinite(offset)) {
throw new Error("Offset must be a non-negative number");
}
}
function validatePadding(padding) {
if (typeof padding !== "number" || padding < 0 || !Number.isFinite(padding)) {
throw new Error("Padding must be a non-negative number");
}
}
// src/utils/constants.ts
var MS_PER_MINUTE = 60 * 1000;
var MS_PER_HOUR = 60 * MS_PER_MINUTE;
var MS_PER_DAY = 24 * MS_PER_HOUR;
var MINUTES_PER_HOUR = 60;
var HOURS_PER_DAY = 24;
// src/helpers/busy-time/padding.ts
function applyPadding(busyTimes, paddingMinutes) {
if (paddingMinutes === 0 || busyTimes.length === 0) {
return busyTimes;
}
const paddingMs = paddingMinutes * MS_PER_MINUTE;
const result = new Array(busyTimes.length);
for (let i = 0;i < busyTimes.length; i++) {
const busyTime = busyTimes[i];
result[i] = {
start: new Date(busyTime.start.getTime() - paddingMs),
end: new Date(busyTime.end.getTime() + paddingMs)
};
}
return result;
}
// src/helpers/busy-time/merge.ts
function mergeBusyTimes(busyTimes) {
if (busyTimes.length <= 1) {
return busyTimes.slice();
}
const sorted = busyTimes.slice().sort((a, b) => a.start.getTime() - b.start.getTime());
const merged = [sorted[0]];
for (let i = 1;i < sorted.length; i++) {
const current = sorted[i];
const lastMerged = merged[merged.length - 1];
if (current.start.getTime() <= lastMerged.end.getTime()) {
if (current.end.getTime() > lastMerged.end.getTime()) {
lastMerged.end = current.end;
}
} else {
merged.push(current);
}
}
return merged;
}
function isOverlapping(time1, time2) {
return time1.start.getTime() < time2.end.getTime() && time2.start.getTime() < time1.end.getTime();
}
// src/helpers/time/alignment.ts
function calculateMinutesFromHour(date) {
return date.getMinutes() + date.getSeconds() / 60 + date.getMilliseconds() / 60000;
}
function alignToInterval(date, intervalMinutes, offsetMinutes = 0) {
const msFromEpoch = date.getTime();
const intervalMs = intervalMinutes * MS_PER_MINUTE;
const offsetMs = offsetMinutes * MS_PER_MINUTE;
const remainder = (msFromEpoch - offsetMs) % intervalMs;
if (remainder === 0) {
return new Date(date);
}
return new Date(msFromEpoch + intervalMs - remainder);
}
function findNextSlotBoundary(date, intervalMinutes, offsetMinutes = 0) {
const msFromEpoch = date.getTime();
const intervalMs = intervalMinutes * MS_PER_MINUTE;
const offsetMs = offsetMinutes * MS_PER_MINUTE;
const remainder = ((msFromEpoch - offsetMs) % intervalMs + intervalMs) % intervalMs;
if (remainder === 0) {
return new Date(date);
}
const minutesToNextBoundary = (intervalMs - remainder) / MS_PER_MINUTE;
return new Date(date.getTime() + minutesToNextBoundary * MS_PER_MINUTE);
}
function getTimeWithinDay(date) {
return date.getHours() * MINUTES_PER_HOUR + date.getMinutes();
}
// src/helpers/slot/generator.ts
function generateSlots(startTime, endTime, options) {
const { slotDurationMinutes, slotSplitMinutes = slotDurationMinutes, offsetMinutes = 0 } = options;
const slots = [];
const slotDurationMs = slotDurationMinutes * MS_PER_MINUTE;
const slotSplitMs = slotSplitMinutes * MS_PER_MINUTE;
const endTimeMs = endTime.getTime();
let currentStart;
if (offsetMinutes === 0) {
currentStart = new Date(startTime);
} else {
currentStart = calculateFirstSlotStart(startTime, slotSplitMinutes, offsetMinutes);
}
let currentStartMs = currentStart.getTime();
while (currentStartMs + slotDurationMs <= endTimeMs) {
slots.push({
start: new Date(currentStartMs),
end: new Date(currentStartMs + slotDurationMs)
});
currentStartMs += slotSplitMs;
}
return slots;
}
function calculateFirstSlotStart(startTime, slotSplitMinutes, offsetMinutes) {
if (offsetMinutes === 0) {
return findNextSlotBoundary(startTime, slotSplitMinutes, 0);
}
const alignedTime = findNextSlotBoundary(startTime, slotSplitMinutes, offsetMinutes);
if (alignedTime.getTime() < startTime.getTime()) {
return new Date(alignedTime.getTime() + slotSplitMinutes * MS_PER_MINUTE);
}
return alignedTime;
}
// src/helpers/busy-time/overlap.ts
function hasOverlap(slot, busyTime) {
if (busyTime.start.getTime() === busyTime.end.getTime()) {
return false;
}
return slot.start.getTime() < busyTime.end.getTime() && busyTime.start.getTime() < slot.end.getTime();
}
function isSlotAvailable(slot, busyTimes) {
const slotStart = slot.start.getTime();
const slotEnd = slot.end.getTime();
for (let i = 0;i < busyTimes.length; i++) {
const busyTime = busyTimes[i];
if (busyTime.start.getTime() >= slotEnd) {
break;
}
if (busyTime.start.getTime() !== busyTime.end.getTime() && slotStart < busyTime.end.getTime() && busyTime.start.getTime() < slotEnd) {
return false;
}
}
return true;
}
// src/helpers/slot/filter.ts
function filterAvailableSlots(slots, busyTimes) {
if (busyTimes.length === 0) {
return slots;
}
const availableSlots = [];
for (let i = 0;i < slots.length; i++) {
const slot = slots[i];
if (isSlotAvailable(slot, busyTimes)) {
availableSlots.push(slot);
}
}
return availableSlots;
}
// src/core/scheduler.ts
class Scheduler {
busyTimes;
constructor(busyTimes = []) {
this.busyTimes = busyTimes.slice().sort((a, b) => a.start.getTime() - b.start.getTime());
}
findAvailableSlots(startTime, endTime, options) {
validateTimeRange(startTime, endTime);
validateOptions(options);
const { slotDuration, padding = 0, slotSplit = slotDuration, offset = 0 } = options;
const paddedBusyTimes = applyPadding(this.busyTimes, padding);
const mergedBusyTimes = mergeBusyTimes(paddedBusyTimes);
const slots = generateSlots(startTime, endTime, {
slotDurationMinutes: slotDuration,
slotSplitMinutes: slotSplit,
offsetMinutes: offset
});
return filterAvailableSlots(slots, mergedBusyTimes);
}
addBusyTime(busyTime) {
this.busyTimes.push(busyTime);
this.busyTimes.sort((a, b) => a.start.getTime() - b.start.getTime());
}
addBusyTimes(busyTimes) {
this.busyTimes.push(...busyTimes);
this.busyTimes.sort((a, b) => a.start.getTime() - b.start.getTime());
}
clearBusyTimes() {
this.busyTimes.length = 0;
}
getBusyTimes() {
return this.busyTimes.slice();
}
}
// src/helpers/time/date-math.ts
function addMinutes(date, minutes) {
return new Date(date.getTime() + minutes * MS_PER_MINUTE);
}
function subtractMinutes(date, minutes) {
return new Date(date.getTime() - minutes * MS_PER_MINUTE);
}
function minutesBetween(start, end) {
return Math.floor((end.getTime() - start.getTime()) / MS_PER_MINUTE);
}
function isSameDay(date1, date2) {
return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate();
}
function startOfDay(date) {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
}
function endOfDay(date) {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
}
// src/helpers/availability/converter.ts
var DAY_MAP = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6
};
function parseTimeString(timeStr) {
const [hoursStr, minutesStr] = timeStr.split(":");
const hours = parseInt(hoursStr, 10);
const minutes = parseInt(minutesStr, 10);
if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
throw new Error(`Invalid time format: ${timeStr}. Expected HH:mm format (e.g., "09:00")`);
}
return { hours, minutes };
}
function weeklyAvailabilityToBusyTimes(availability, weekStart) {
if (weekStart.getDay() !== 1) {
throw new Error("weekStart must be a Monday (getDay() === 1)");
}
const busyTimes = [];
for (let dayOffset = 0;dayOffset < 7; dayOffset++) {
const currentDay = new Date(weekStart);
currentDay.setDate(weekStart.getDate() + dayOffset);
const dayOfWeek = currentDay.getDay();
const daySchedules = [];
for (const schedule of availability.schedules) {
const startTime = parseTimeString(schedule.start);
const endTime = parseTimeString(schedule.end);
if (startTime.hours > endTime.hours || startTime.hours === endTime.hours && startTime.minutes >= endTime.minutes) {
throw new Error(`Invalid time range: ${schedule.start} to ${schedule.end}. Start must be before end.`);
}
for (const dayName of schedule.days) {
if (DAY_MAP[dayName] === dayOfWeek) {
daySchedules.push({
start: startTime.hours * 60 + startTime.minutes,
end: endTime.hours * 60 + endTime.minutes
});
}
}
}
daySchedules.sort((a, b) => a.start - b.start);
const dayStart = startOfDay(currentDay);
const dayEnd = endOfDay(currentDay);
if (daySchedules.length === 0) {
busyTimes.push({ start: dayStart, end: dayEnd });
continue;
}
if (daySchedules[0].start > 0) {
const busyEnd = new Date(dayStart);
busyEnd.setMinutes(daySchedules[0].start);
busyTimes.push({ start: dayStart, end: busyEnd });
}
for (let i = 0;i < daySchedules.length - 1; i++) {
const currentEnd = daySchedules[i].end;
const nextStart = daySchedules[i + 1].start;
if (currentEnd < nextStart) {
const busyStart = new Date(dayStart);
busyStart.setMinutes(currentEnd);
const busyEnd = new Date(dayStart);
busyEnd.setMinutes(nextStart);
busyTimes.push({ start: busyStart, end: busyEnd });
}
}
const lastSchedule = daySchedules[daySchedules.length - 1];
if (lastSchedule.end < 24 * 60) {
const busyStart = new Date(dayStart);
busyStart.setMinutes(lastSchedule.end);
busyTimes.push({ start: busyStart, end: dayEnd });
}
}
return busyTimes;
}
// src/validators/availability.validator.ts
var VALID_DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
function isValidTimeFormat(time) {
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
return timeRegex.test(time);
}
function parseTime(time) {
const parts = time.split(":").map(Number);
const hours = parts[0] ?? 0;
const minutes = parts[1] ?? 0;
return hours * 60 + minutes;
}
function validateWeeklyAvailability(availability) {
if (availability === undefined) {
return;
}
if (typeof availability !== "object" || availability === null) {
throw new Error("Availability must be an object");
}
if (!Array.isArray(availability.schedules)) {
throw new Error("Availability.schedules must be an array");
}
if (availability.schedules.length === 0) {
throw new Error("Availability.schedules cannot be empty");
}
if (availability.timezone !== undefined) {
if (typeof availability.timezone !== "string" || availability.timezone.trim() === "") {
throw new Error("Availability.timezone must be a non-empty string");
}
if (!/^[A-Za-z_]+\/[A-Za-z_]+$/.test(availability.timezone)) {
throw new Error('Availability.timezone must be a valid IANA timezone identifier (e.g., "America/New_York")');
}
}
for (let i = 0;i < availability.schedules.length; i++) {
const schedule = availability.schedules[i];
if (!schedule || typeof schedule !== "object") {
throw new Error(`Schedule at index ${i} must be an object`);
}
if (!Array.isArray(schedule.days)) {
throw new Error(`Schedule at index ${i}: days must be an array`);
}
if (schedule.days.length === 0) {
throw new Error(`Schedule at index ${i}: days array cannot be empty`);
}
for (const day of schedule.days) {
if (!VALID_DAYS.includes(day)) {
throw new Error(`Schedule at index ${i}: invalid day "${day}". Valid days: ${VALID_DAYS.join(", ")}`);
}
}
const uniqueDays = new Set(schedule.days);
if (uniqueDays.size !== schedule.days.length) {
throw new Error(`Schedule at index ${i}: duplicate days found`);
}
if (typeof schedule.start !== "string" || !isValidTimeFormat(schedule.start)) {
throw new Error(`Schedule at index ${i}: start must be in HH:mm format (e.g., "09:00")`);
}
if (typeof schedule.end !== "string" || !isValidTimeFormat(schedule.end)) {
throw new Error(`Schedule at index ${i}: end must be in HH:mm format (e.g., "17:00")`);
}
const startMinutes = parseTime(schedule.start);
const endMinutes = parseTime(schedule.end);
if (startMinutes >= endMinutes) {
throw new Error(`Schedule at index ${i}: start time (${schedule.start}) must be before end time (${schedule.end})`);
}
}
const daySchedules = new Map;
for (let i = 0;i < availability.schedules.length; i++) {
const schedule = availability.schedules[i];
const startMinutes = parseTime(schedule.start);
const endMinutes = parseTime(schedule.end);
for (const day of schedule.days) {
if (!daySchedules.has(day)) {
daySchedules.set(day, []);
}
const existing = daySchedules.get(day);
for (const existingSchedule of existing) {
if (startMinutes < existingSchedule.end && existingSchedule.start < endMinutes) {
throw new Error(`Overlapping schedules found for ${day}: schedule ${i} (${schedule.start}-${schedule.end}) overlaps with schedule ${existingSchedule.index}`);
}
}
existing.push({ start: startMinutes, end: endMinutes, index: i });
}
}
}
// src/availability/scheduler.ts
class AvailabilityScheduler {
scheduler;
availability;
constructor(availability, existingBusyTimes = []) {
validateWeeklyAvailability(availability);
this.availability = availability;
this.scheduler = new Scheduler(existingBusyTimes);
}
setAvailability(availability) {
validateWeeklyAvailability(availability);
this.availability = availability;
}
getAvailability() {
return this.availability;
}
addBusyTime(busyTime) {
this.scheduler.addBusyTimes([busyTime]);
}
addBusyTimes(busyTimes) {
this.scheduler.addBusyTimes(busyTimes);
}
clearBusyTimes() {
this.scheduler.clearBusyTimes();
}
getBusyTimes() {
return this.scheduler.getBusyTimes();
}
findAvailableSlots(startTime, endTime, options) {
if (!this.availability) {
return this.scheduler.findAvailableSlots(startTime, endTime, options);
}
const weekStart = this.getMonday(startTime);
const availabilityBusyTimes = weeklyAvailabilityToBusyTimes(this.availability, weekStart);
const tempScheduler = new Scheduler([...this.scheduler.getBusyTimes(), ...availabilityBusyTimes]);
return tempScheduler.findAvailableSlots(startTime, endTime, options);
}
getMonday(date) {
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(date);
monday.setDate(date.getDate() + diff);
return startOfDay(monday);
}
}
// src/index.ts
function createScheduler(busyTimes = []) {
return new Scheduler(busyTimes);
}
export {
workDays,
weeklyAvailabilityToBusyTimes,
weekendDays,
validateWeeklyAvailability,
validateTimeRange,
validateSplit,
validatePadding,
validateOptions,
validateOffset,
validateDuration,
subtractMinutes,
startOfDay,
minutesBetween,
mergeBusyTimes,
isSlotAvailable,
isSameDay,
isOverlapping,
hasOverlap,
getTimeWithinDay,
generateSlots,
findNextSlotBoundary,
filterAvailableSlots,
endOfDay,
createScheduler,
calculateMinutesFromHour,
calculateFirstSlotStart,
applyPadding,
alignToInterval,
addMinutes,
Scheduler,
MS_PER_MINUTE,
MS_PER_HOUR,
MS_PER_DAY,
MINUTES_PER_HOUR,
HOURS_PER_DAY,
AvailabilityScheduler
};