import dayjs, { Dayjs } from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; dayjs.extend(utc); dayjs.extend(timezone); type WorkingHour = { days: number[]; startTime: number; endTime: number; }; type GetSlots = { inviteeDate: Dayjs; frequency: number; workingHours: WorkingHour[]; minimumBookingNotice?: number; organizerTimeZone: string; }; type Boundary = { lowerBound: number; upperBound: number; }; const freqApply = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency; const intersectBoundary = (a: Boundary, b: Boundary) => { if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) { return; } return { lowerBound: Math.max(b.lowerBound, a.lowerBound), upperBound: Math.min(b.upperBound, a.upperBound), }; }; // say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240 const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) => boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean); const organizerBoundaries = ( workingHours: [], inviteeDate: Dayjs, inviteeBounds: Boundary, organizerTimeZone ): Boundary[] => { const boundaries: Boundary[] = []; const startDay: number = +inviteeDate.startOf("d").add(inviteeBounds.lowerBound, "minutes").format("d"); const endDay: number = +inviteeDate.startOf("d").add(inviteeBounds.upperBound, "minutes").format("d"); workingHours.forEach((item) => { const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset(); const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset(); if (startDay !== endDay) { if (inviteeBounds.lowerBound < 0) { // lowerBound edges into the previous day if (item.days.includes(startDay)) { boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 }); } if (item.days.includes(endDay)) { boundaries.push({ lowerBound, upperBound }); } } else { // upperBound edges into the next day if (item.days.includes(endDay)) { boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 }); } if (item.days.includes(startDay)) { boundaries.push({ lowerBound, upperBound }); } } } else { if (item.days.includes(startDay)) { boundaries.push({ lowerBound, upperBound }); } } }); return boundaries; }; const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => { const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency); const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency); return { lowerBound, upperBound, }; }; const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => { const slots: Dayjs[] = []; for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) { slots.push( dayjs .utc() .startOf("d") .add(lowerBound + minutes, "minutes") ); } return slots; }; const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours, organizerTimeZone, }: GetSlots): Dayjs[] => { // current date in invitee tz const currentDate = dayjs().utcOffset(inviteeDate.utcOffset()); const startDate = currentDate.add(minimumBookingNotice, "minutes"); // + minimum notice period const startTime = startDate.isAfter(inviteeDate) ? // block out everything when inviteeDate is less than startDate startDate.diff(inviteeDate, "day") > 0 ? 1440 : startDate.hour() * 60 + startDate.minute() : 0; const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency); return getOverlaps( inviteeBounds, organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone) ) .reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], []) .map((slot) => slot.utcOffset(inviteeDate.utcOffset()).month(inviteeDate.month()).date(inviteeDate.date()) ); }; export default getSlots;