diff --git a/src/components/Editor/FreeBusy/FreeBusy.vue b/src/components/Editor/FreeBusy/FreeBusy.vue index d69a80a54..23443c78c 100644 --- a/src/components/Editor/FreeBusy/FreeBusy.vue +++ b/src/components/Editor/FreeBusy/FreeBusy.vue @@ -456,13 +456,13 @@ export default { try { // for now search slots only in the first week days const endSearchDate = new Date(startSearch) - endSearchDate.setDate(startSearch.getDate() + 7) + endSearchDate.setDate(startSearch.getDate() + 8) const eventResults = await getBusySlots( this.organizer.attendeeProperty, this.attendees.map((a) => a.attendeeProperty), startSearch, endSearchDate, - this.timeZoneId + this.timeZoneId, ) const freeSlots = getFirstFreeSlot( diff --git a/src/services/freeBusySlotService.js b/src/services/freeBusySlotService.js index 3cca3d298..f38996133 100644 --- a/src/services/freeBusySlotService.js +++ b/src/services/freeBusySlotService.js @@ -9,6 +9,8 @@ import freeBusyResourceEventSourceFunction from '../fullcalendar/eventSources/fr import getTimezoneManager from './timezoneDataProviderService.js' import logger from '../utils/logger.js' +const daysToSearch = 7 + /** * Get the first available slot for an event using freebusy API * @@ -20,6 +22,9 @@ import logger from '../utils/logger.js' * @return {Promise<>} */ export async function getBusySlots(organizer, attendees, start, end, timeZoneId) { + // We start searching a day earlier because if we don't availability events get cut off in weird ways + const clonedStart = new Date(start.getTime()) + clonedStart.setDate(clonedStart.getDate() - 1) let timezoneObject = getTimezoneManager().getTimezoneForId(timeZoneId) if (!timezoneObject) { @@ -27,7 +32,7 @@ export async function getBusySlots(organizer, attendees, start, end, timeZoneId) logger.error(`FreeBusyEventSource: Timezone ${timeZoneId} not found, falling back to UTC.`) } - const startDateTime = DateTimeValue.fromJSDate(start, true) + const startDateTime = DateTimeValue.fromJSDate(clonedStart, true) const endDateTime = DateTimeValue.fromJSDate(end, true) const organizerAsAttendee = new AttendeeProperty('ATTENDEE', organizer.email) @@ -47,6 +52,7 @@ export async function getBusySlots(organizer, attendees, start, end, timeZoneId) } catch (error) { return { error } } + const events = [] for (const [uri, data] of Object.entries(freeBusyData)) { events.push(...freeBusyResourceEventSourceFunction(uri, data.calendarData, data.success, startDateTime, endDateTime, timezoneObject)) @@ -65,56 +71,45 @@ export async function getBusySlots(organizer, attendees, start, end, timeZoneId) * @return [] */ export function getFirstFreeSlot(start, end, retrievedEvents) { + + // Here we are trying to understand the duration of the event, this is needed to check that the start and end points of a theoretical slot are free let duration = getDurationInSeconds(start, end) if (duration === 0) { - duration = 86400 // one day + duration = 86400 // One day } + + // We check all events in a weekly span from the start of the event const endSearchDate = new Date(start) - endSearchDate.setDate(start.getDate() + 7) + endSearchDate.setDate(start.getDate() + daysToSearch) if (retrievedEvents.error) { return [{ error: retrievedEvents.error }] } - const events = sortEvents(retrievedEvents) + // Events have to be sorted to be checked cronologically + let events = sortEvents(retrievedEvents) - let currentCheckedTime = start - const currentCheckedTimeEnd = new Date(currentCheckedTime) - currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration) - const foundSlots = [] - let offset = 1 + events = events.filter(function(event) { + return new Date(start) < new Date(event.end) + }) - if (new Date(events[0]?.start) < currentCheckedTime) { - offset = 0 - } + const totalSlots = [] - for (let i = 0; i < events.length + offset && i < 5; i++) { - foundSlots[i] = checkTimes(currentCheckedTime, duration, events) + // Check times after every event + for (let i = 0; i < events.length; i++) { + const foundSlots = checkTime(new Date(events[i].end), duration, events) - if (foundSlots[i].nextEvent !== undefined && foundSlots[i].nextEvent !== null) { - currentCheckedTime = new Date(foundSlots[i].nextEvent.end) - } - // avoid repetitions caused by events blocking at first iteration of currentCheckedTime - if (foundSlots[i]?.start === foundSlots[i - 1]?.start && foundSlots[i] !== undefined) { - foundSlots[i] = {} - break - } + if (foundSlots) totalSlots.push(foundSlots) } - const roundedSlots = [] - - foundSlots.forEach((slot) => { - const roundedTime = roundTime(slot.start, slot.end, slot.blockingEvent, slot.nextEvent, duration) + // Check current time + const foundSlots = checkTime(new Date(start), duration, events, false, false) - if (roundedTime !== null && roundedTime.start < endSearchDate) { - roundedSlots.push({ - start: roundedTime.start, - end: roundedTime.end, - }) - } - }) + if (foundSlots) { + totalSlots.unshift(foundSlots) + } - return roundedSlots + return totalSlots } /** @@ -124,130 +119,73 @@ export function getFirstFreeSlot(start, end, retrievedEvents) { * @return {number} */ function getDurationInSeconds(start, end) { - // convert dates to UTC to account for daylight saving time + // Convert dates to UTC to account for daylight saving time const startUTC = new Date(start).toUTCString() const endUTC = new Date(end).toUTCString() const durationMs = new Date(endUTC) - new Date(startUTC) - // convert milliseconds to seconds + // Convert milliseconds to seconds return Math.floor(durationMs / 1000) } -/** - * - * @param currentCheckedTime - * @param currentCheckedTimeEnd - * @param blockingEvent - * @param nextEvent - * @param duration - */ -function roundTime(currentCheckedTime, currentCheckedTimeEnd, blockingEvent, nextEvent, duration) { - if (currentCheckedTime === null) return null - if (!blockingEvent) return { start: currentCheckedTime, end: currentCheckedTimeEnd } - - // make sure that difference between currentCheckedTime and blockingEvent.end is at least 15 minutes - if ((currentCheckedTime - new Date(blockingEvent.end)) / (1000 * 60) < 15) { - currentCheckedTime.setMinutes(currentCheckedTime.getMinutes() + 15) - } - - // needed to fix edge case errors - if ((currentCheckedTime - new Date(blockingEvent.end)) / (1000 * 60) > 15) { - currentCheckedTime.setMinutes(currentCheckedTime.getMinutes() - 15) - } - - // round to the nearest 30 minutes - if (currentCheckedTime.getMinutes() < 30) { - currentCheckedTime.setMinutes(30) - } else { - currentCheckedTime.setMinutes(0) - currentCheckedTime.setHours(currentCheckedTime.getHours() + 1) - } - - // update currentCheckedTimeEnd again since currentCheckedTime was updated - currentCheckedTimeEnd = new Date(currentCheckedTime) - currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration) - - // if the rounding of the event doesn't conflict with the start of the next one - if (currentCheckedTimeEnd > new Date(nextEvent?.start)) { - return null - } - - return { start: currentCheckedTime, end: currentCheckedTimeEnd } -} - /** * * @param currentCheckedTime * @param duration * @param events + * @param toRound + * @parma toRound */ -function checkTimes(currentCheckedTime, duration, events) { - let slotIsBusy = false - let blockingEvent = null - let nextEvent = null - let currentCheckedTimeEnd = new Date(currentCheckedTime) - currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration) +function checkTime(currentCheckedTime, duration, events, toRound = true) { + let timeValid = true - // loop every 5 minutes since start date - // check if there are no events in the duration starting from that minute - while (true) { - events.every( - (event) => { - slotIsBusy = false - - const eventStart = new Date(event.start) - const eventEnd = new Date(event.end) - - currentCheckedTimeEnd = new Date(currentCheckedTime) - currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration) - - // start of event is within the range that we are checking - if (eventStart >= currentCheckedTime && eventStart <= currentCheckedTimeEnd) { - slotIsBusy = true - blockingEvent = event - return false - } - - // end of event is within range that we are checking - if (eventEnd >= currentCheckedTime && eventEnd <= currentCheckedTimeEnd) { - slotIsBusy = true - blockingEvent = event - return false - } - - // range that we are checking is within ends of event - if (eventStart <= currentCheckedTime && eventEnd >= currentCheckedTimeEnd) { - slotIsBusy = true - blockingEvent = event - return false - } - return true - }, - ) - - if (slotIsBusy) { - currentCheckedTime.setMinutes(currentCheckedTime.getMinutes() + 5) - } else break + // We sometimes don't want to round, like when using the current time + if (toRound) { + currentCheckedTime = roundToNearestQuarter(currentCheckedTime) } - if (blockingEvent !== null) { - const blockingIndex = events.findIndex((event) => event === blockingEvent) + const currentCheckedTimeEnd = new Date(currentCheckedTime) + currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration) - nextEvent = events[blockingIndex + 1] + events.every( + (event) => { + const eventStart = new Date(event.start) + const eventEnd = new Date(event.end) + + // Start of event is within the range that we are checking + if (eventStart >= currentCheckedTime && eventStart <= currentCheckedTimeEnd) { + timeValid = false + return false + } + + // End of event is within range that we are checking + if (eventEnd >= currentCheckedTime && eventEnd <= currentCheckedTimeEnd) { + timeValid = false + return false + } + + // Range that we are checking is within ends of event + if (eventStart <= currentCheckedTime && eventEnd >= currentCheckedTimeEnd) { + timeValid = false + return false + } + return true + }, + ) + + if (timeValid) { + return { start: currentCheckedTime, end: currentCheckedTimeEnd } } else { - if (events.length > 0) nextEvent = events[0] + return false } - - return { start: currentCheckedTime, end: currentCheckedTimeEnd, nextEvent, blockingEvent } } -// make a function that sorts a list of objects by the "start" property /** * * @param events */ function sortEvents(events) { - // remove events that have the same start and end time, if not done causes problems + // Remove events that have the same start and end time, if not done causes problems const mappedEvents = new Map() for (const obj of events) { @@ -260,3 +198,22 @@ function sortEvents(events) { return Array.from(mappedEvents.values()).sort((a, b) => new Date(a.start) - new Date(b.start)) } + +/** + * + * @param date + */ +function roundToNearestQuarter(date) { + // Needed because it doesn't work with 0 + if (date.getMinutes() % 15 === 0) date.setMinutes(date.getMinutes() + 1) + + const roundedMinutes = Math.ceil(date.getMinutes() / 15) * 15 + + date.setMinutes(roundedMinutes) + + // Reset seconds and milliseconds + date.setSeconds(0) + date.setMilliseconds(0) + + return date +} diff --git a/tests/javascript/unit/services/freeBusySlotService.test.js b/tests/javascript/unit/services/freeBusySlotService.test.js index 97ec05e38..e1b89ad69 100644 --- a/tests/javascript/unit/services/freeBusySlotService.test.js +++ b/tests/javascript/unit/services/freeBusySlotService.test.js @@ -19,8 +19,8 @@ describe('services/freeBusySlotService test suite', () => { const result = getFirstFreeSlot(start, end, events) - expect(result[0].start).toEqual(new Date('2024-01-01T10:30:00Z')) - expect(result[0].end).toEqual(new Date('2024-01-01T11:30:00Z')) + expect(result[0].start).toEqual(new Date('2024-01-01T10:15:00Z')) + expect(result[0].end).toEqual(new Date('2024-01-01T11:15:00Z')) }) it('should return the same amount of suggested slots as events plus one if first blocking event starts after searched time', () => { @@ -47,8 +47,8 @@ describe('services/freeBusySlotService test suite', () => { expect(result.length).toEqual(events.length + 1) - expect(result[3].start).toEqual(new Date('2024-01-02T19:30:00Z')) - expect(result[3].end).toEqual(new Date('2024-01-02T20:15:00Z')) + expect(result[3].start).toEqual(new Date('2024-01-02T19:15:00Z')) + expect(result[3].end).toEqual(new Date('2024-01-02T20:00:00Z')) }) it('should return the same amount of suggested slots as events if first blocking event conflicts with searched time', () => { @@ -75,8 +75,8 @@ describe('services/freeBusySlotService test suite', () => { expect(result.length).toEqual(events.length) - expect(result[2].start).toEqual(new Date('2024-01-02T19:30:00Z')) - expect(result[2].end).toEqual(new Date('2024-01-02T20:15:00Z')) + expect(result[2].start).toEqual(new Date('2024-01-02T19:15:00Z')) + expect(result[2].end).toEqual(new Date('2024-01-02T20:00:00Z')) }) it('should not give slots between events if the difference is smaller than the searched time duration', () => { @@ -97,7 +97,7 @@ describe('services/freeBusySlotService test suite', () => { const result = getFirstFreeSlot(start, end, events) - expect(result[0].start).toEqual(new Date('2024-01-01T16:30:00Z')) + expect(result[0].start).toEqual(new Date('2024-01-01T16:15:00Z')) }) })