scheduling-sdk
Version:
Brought to you by Recal - A TypeScript SDK for scheduling functionality
616 lines (602 loc) • 22.9 kB
JavaScript
// 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/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/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/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/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/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/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/timezone.ts
function convertTimeStringToUTC(timeStr, date, timezone) {
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")`);
}
try {
if (!isValidTimezone(timezone)) {
throw new Error(`Invalid timezone: ${timezone}`);
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const timeString = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:00`;
const isoString = `${year}-${month}-${day}T${timeString}.000`;
const testDate = new Date(`${isoString}Z`);
const offsetMinutes = getTimezoneOffsetMinutes(testDate, timezone);
return new Date(testDate.getTime() + offsetMinutes * 60 * 1000);
} catch {
throw new Error(`Invalid timezone: ${timezone}. Must be a valid IANA timezone identifier.`);
}
}
function getTimezoneOffsetMinutes(date, timezone) {
const utcTime = date.getTime();
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
});
const parts = formatter.formatToParts(date);
const year = parseInt(parts.find((p) => p.type === "year")?.value || "0");
const month = parseInt(parts.find((p) => p.type === "month")?.value || "1");
const day = parseInt(parts.find((p) => p.type === "day")?.value || "1");
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0");
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0");
const second = parseInt(parts.find((p) => p.type === "second")?.value || "0");
const localAsUTC = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
return (utcTime - localAsUTC.getTime()) / (60 * 1000);
}
function isValidTimezone(timezone) {
try {
if (!timezone || timezone.includes(" ") || timezone.length < 3) {
return false;
}
if (timezone === "UTC") {
return true;
}
if (!timezone.includes("/") && !["GMT"].includes(timezone)) {
return false;
}
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
return true;
} catch {
return false;
}
}
// src/helpers/availability/converter.ts
var DAY_MAP = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6
};
function parseTime(time) {
if (typeof time === "number") {
return { hours: time / 60, minutes: time % 60 };
}
const [hoursStr, minutesStr] = time.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: ${time}. Expected HH:mm or number of minutes (e.g., "09:00" or 540)`);
}
return { hours, minutes };
}
function generateBusyTimesInNativeTimezone(availability, weekStartInTimezone, timezone) {
const busyTimes = [];
const startOffset = timezone !== "UTC" ? -1 : 0;
for (let dayOffset = startOffset;dayOffset < 7; dayOffset++) {
const currentDay = new Date(Date.UTC(weekStartInTimezone.getFullYear(), weekStartInTimezone.getMonth(), weekStartInTimezone.getDate() + dayOffset));
const dayOfWeek = currentDay.getUTCDay();
const daySchedules = [];
for (const schedule of availability.schedules) {
const startTime = parseTime(schedule.start);
const endTime = parseTime(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) {
const startDate = new Date(Date.UTC(currentDay.getUTCFullYear(), currentDay.getUTCMonth(), currentDay.getUTCDate(), startTime.hours, startTime.minutes));
const endDate = new Date(Date.UTC(currentDay.getUTCFullYear(), currentDay.getUTCMonth(), currentDay.getUTCDate(), endTime.hours, endTime.minutes));
daySchedules.push({ start: startDate, end: endDate });
}
}
}
daySchedules.sort((a, b) => a.start.getTime() - b.start.getTime());
const dayStart = new Date(Date.UTC(currentDay.getUTCFullYear(), currentDay.getUTCMonth(), currentDay.getUTCDate(), 0, 0, 0, 0));
const dayEnd = new Date(Date.UTC(currentDay.getUTCFullYear(), currentDay.getUTCMonth(), currentDay.getUTCDate(), 23, 59, 59, 999));
if (daySchedules.length === 0) {
busyTimes.push({ start: dayStart, end: dayEnd });
continue;
}
if (daySchedules[0].start.getTime() > dayStart.getTime()) {
busyTimes.push({ start: dayStart, end: daySchedules[0].start });
}
for (let i = 0;i < daySchedules.length - 1; i++) {
const currentEnd = daySchedules[i].end;
const nextStart = daySchedules[i + 1].start;
if (currentEnd.getTime() < nextStart.getTime()) {
busyTimes.push({ start: currentEnd, end: nextStart });
}
}
const lastSchedule = daySchedules[daySchedules.length - 1];
if (lastSchedule.end.getTime() < dayEnd.getTime()) {
busyTimes.push({ start: lastSchedule.end, end: dayEnd });
}
}
return busyTimes;
}
function weeklyAvailabilityToBusyTimes(availability, weekStart, timezone) {
if (weekStart.getDay() !== 1) {
throw new Error("weekStart must be a Monday (getDay() === 1)");
}
const resolvedTimezone = timezone || process.env.SCHEDULING_TIMEZONE || "UTC";
const weekStartInTimezone = new Date(Date.UTC(weekStart.getUTCFullYear(), weekStart.getUTCMonth(), weekStart.getUTCDate()));
const busyTimesInTimezone = generateBusyTimesInNativeTimezone(availability, weekStartInTimezone, resolvedTimezone);
if (resolvedTimezone === "UTC") {
return busyTimesInTimezone;
}
const busyTimesUTC = [];
for (const busyTime of busyTimesInTimezone) {
const startTimeStr = `${String(busyTime.start.getUTCHours()).padStart(2, "0")}:${String(busyTime.start.getUTCMinutes()).padStart(2, "0")}`;
const endTimeStr = `${String(busyTime.end.getUTCHours()).padStart(2, "0")}:${String(busyTime.end.getUTCMinutes()).padStart(2, "0")}`;
const startDate = new Date(busyTime.start.getUTCFullYear(), busyTime.start.getUTCMonth(), busyTime.start.getUTCDate());
const endDate = new Date(busyTime.end.getUTCFullYear(), busyTime.end.getUTCMonth(), busyTime.end.getUTCDate());
const startUTC = convertTimeStringToUTC(startTimeStr, startDate, resolvedTimezone);
const endUTC = convertTimeStringToUTC(endTimeStr, endDate, resolvedTimezone);
busyTimesUTC.push({ start: startUTC, end: endUTC });
}
return busyTimesUTC;
}
// 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 parseTime2(time) {
if (typeof time === "number")
return 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");
}
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`);
}
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 = parseTime2(schedule.start);
const endMinutes = parseTime2(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 = parseTime2(schedule.start);
const endMinutes = parseTime2(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;
timezone;
constructor(availability, timezone, existingBusyTimes = []) {
validateWeeklyAvailability(availability);
this.availability = availability;
this.timezone = this.resolveAndValidateTimezone(timezone);
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 availabilityBusyTimes = this.generateAvailabilityBusyTimes(startTime, endTime);
const allBusyTimes = [...this.scheduler.getBusyTimes(), ...availabilityBusyTimes];
const tempScheduler = new Scheduler(allBusyTimes);
return tempScheduler.findAvailableSlots(startTime, endTime, options);
}
resolveAndValidateTimezone(timezone) {
if (timezone !== undefined) {
return this.validateTimezone(timezone);
}
const fallbackTimezone = process.env.SCHEDULING_TIMEZONE || "UTC";
return fallbackTimezone === "UTC" ? fallbackTimezone : this.validateTimezone(fallbackTimezone);
}
validateTimezone(timezone) {
if (timezone === "") {
throw new Error(`Invalid timezone: ${timezone}. Must be a valid IANA timezone identifier.`);
}
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
return timezone;
} catch {
throw new Error(`Invalid timezone: ${timezone}. Must be a valid IANA timezone identifier.`);
}
}
generateAvailabilityBusyTimes(startTime, endTime) {
const firstWeekStart = this.getMonday(startTime);
const lastWeekStart = this.getMonday(endTime);
const allBusyTimes = [];
for (let weekStart = new Date(firstWeekStart);weekStart <= lastWeekStart; weekStart.setDate(weekStart.getDate() + 7)) {
const weekBusyTimes = weeklyAvailabilityToBusyTimes(this.availability, new Date(weekStart), this.timezone);
const filteredBusyTimes = this.filterBusyTimesToRange(weekBusyTimes, startTime, endTime);
allBusyTimes.push(...filteredBusyTimes);
}
return allBusyTimes;
}
filterBusyTimesToRange(busyTimes, startTime, endTime) {
return busyTimes.filter((busyTime) => busyTime.start < endTime && busyTime.end > startTime).map((busyTime) => ({
start: new Date(Math.max(busyTime.start.getTime(), startTime.getTime())),
end: new Date(Math.min(busyTime.end.getTime(), endTime.getTime()))
}));
}
getMonday(date) {
const day = date.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(date);
monday.setUTCDate(date.getUTCDate() + diff);
monday.setUTCHours(0, 0, 0, 0);
return monday;
}
}
// 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/types/availability.types.ts
var workDays = ["monday", "tuesday", "wednesday", "thursday", "friday"];
var weekendDays = ["saturday", "sunday"];
// src/index.ts
function createScheduler(busyTimes = []) {
return new Scheduler(busyTimes);
}
function createAvailabilityScheduler(availability, timezone) {
return new AvailabilityScheduler(availability, timezone);
}
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,
createAvailabilityScheduler,
calculateMinutesFromHour,
calculateFirstSlotStart,
applyPadding,
alignToInterval,
addMinutes,
Scheduler,
MS_PER_MINUTE,
MS_PER_HOUR,
MS_PER_DAY,
MINUTES_PER_HOUR,
HOURS_PER_DAY,
AvailabilityScheduler
};