UNPKG

scheduling-sdk

Version:

Brought to you by Recal - A TypeScript SDK for scheduling functionality

616 lines (602 loc) 22.9 kB
// 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 };