Enh/automatic free slot finding and add tests

Signed-off-by: Grigory Vodyanov <scratchx@gmx.com>
This commit is contained in:
Grigory Vodyanov 2024-03-14 23:26:50 +01:00
parent 6ea485f3fc
commit 6b4845ac8d
3 changed files with 191 additions and 32 deletions

View File

@ -168,7 +168,7 @@ import HelpCircleIcon from 'vue-material-design-icons/HelpCircle.vue'
import InviteesListSearch from '../Invitees/InviteesListSearch.vue'
import { getColorForFBType } from '../../../utils/freebusy.js'
import { getFirstFreeSlot } from '../../../services/freeBusySlotService.js'
import { getFirstFreeSlot, getBusySlots } from '../../../services/freeBusySlotService.js'
import dateFormat from '../../../filters/dateFormat.js'
export default {
@ -275,7 +275,7 @@ export default {
]
},
formattedCurrentStart() {
return this.currentStart.toLocaleDateString(this.lang, this.formattingOptions)
return this.currentDate.toLocaleDateString(this.lang, this.formattingOptions)
},
formattedCurrentTime() {
const options = { hour: '2-digit', minute: '2-digit', hour12: true }
@ -477,12 +477,21 @@ export default {
endSearch.setYear(this.currentDate.getFullYear())
try {
const freeSlots = await getFirstFreeSlot(
// for now search slots only in the first week days
const endSearchDate = new Date(startSearch)
endSearchDate.setDate(startSearch.getDate() + 7)
const eventResults = await getBusySlots(
this.organizer.attendeeProperty,
this.attendees.map((a) => a.attendeeProperty),
startSearch,
endSearchDate,
this.timeZoneId
)
const freeSlots = getFirstFreeSlot(
startSearch,
endSearch,
this.timezoneId,
eventResults.events,
)
freeSlots.forEach((slot) => {
@ -506,6 +515,8 @@ export default {
// have to make these "selected" version of the props seeing as they can't be modified directly, and they aren't updated reactively when vuex is
this.currentStart = slot.start
this.currentEnd = slot.end
const clonedDate = new Date(slot.start) // so as not to modify slot.start
this.currentDate = new Date(clonedDate.setHours(0, 0, 0, 0))
},
},
}

View File

@ -33,7 +33,6 @@ import logger from '../utils/logger.js'
* @param {AttendeeProperty[]} attendees Array of the event's attendees
* @param {Date} start The start date and time of the event
* @param {Date} end The end date and time of the event
* @param timeZone Timezone of the user
* @param timeZoneId
* @return {Promise<>}
*/
@ -74,66 +73,72 @@ export async function getBusySlots(organizer, attendees, start, end, timeZoneId)
}
/**
* Get the first available slot for an event using freebusy API
* Get the first available slot for an event using the freebusy API
*
* @param {AttendeeProperty} organizer The organizer of the event
* @param {AttendeeProperty[]} attendees Array of the event's attendees
* @param {Date} start The start date and time of the event
* @param {Date} end The end date and time of the event
* @param timeZoneId TimezoneId of the user
* @return {Promise<[]>}
* @param retrievedEvents Events found by the freebusy API
* @return []
*/
export async function getFirstFreeSlot(organizer, attendees, start, end, timeZoneId) {
export function getFirstFreeSlot(start, end, retrievedEvents) {
let duration = getDurationInSeconds(start, end)
if (duration === 0) {
duration = 86400 // one day
}
// for now search slots only in the first five days
const endSearchDate = new Date(start)
endSearchDate.setDate(start.getDate() + 5)
const eventResults = await getBusySlots(organizer, attendees, start, endSearchDate, timeZoneId)
endSearchDate.setDate(start.getDate() + 7)
if (eventResults.error) {
return [{ error: eventResults.error }]
if (retrievedEvents.error) {
return [{ error: retrievedEvents.error }]
}
const events = eventResults.events
const events = sortEvents(retrievedEvents)
let currentCheckedTime = start
const currentCheckedTimeEnd = new Date(currentCheckedTime)
currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration)
const foundSlots = []
let offset = 1
// more than 1 suggestions is too much
// todo: make it 5
for (let i = 0; (i < events.length + 1 && i < 1); i++) {
if (new Date(events[0]?.start) < currentCheckedTime) {
offset = 0
}
for (let i = 0; i < events.length + offset && i < 5; i++) {
foundSlots[i] = checkTimes(currentCheckedTime, duration, events)
if (foundSlots[i].nextEvent !== undefined && foundSlots[i].nextEvent !== null) currentCheckedTime = new Date(foundSlots[i].nextEvent.end)
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.pop()
if (foundSlots[i]?.start === foundSlots[i - 1]?.start && foundSlots[i] !== undefined) {
foundSlots[i] = {}
break
}
}
foundSlots.forEach((slot, index) => {
const roundedTime = roundTime(slot.start, slot.end, slot.blockingEvent, duration)
const roundedSlots = []
foundSlots[index].start = roundedTime.start
foundSlots[index].end = roundedTime.end
// not needed anymore
foundSlots[index].nextEvent = undefined
foundSlots.forEach((slot) => {
const roundedTime = roundTime(slot.start, slot.end, slot.blockingEvent, slot.nextEvent, duration)
if (roundedTime !== null && roundedTime.start < endSearchDate) {
roundedSlots.push({
start: roundedTime.start,
end: roundedTime.end,
})
}
})
return foundSlots
return roundedSlots
}
/**
*
* @param start
* @param end
* @return {number}
*/
function getDurationInSeconds(start, end) {
// convert dates to UTC to account for daylight saving time
@ -150,9 +155,11 @@ function getDurationInSeconds(start, end) {
* @param currentCheckedTime
* @param currentCheckedTimeEnd
* @param blockingEvent
* @param nextEvent
* @param duration
*/
function roundTime(currentCheckedTime, currentCheckedTimeEnd, blockingEvent, 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
@ -177,6 +184,11 @@ function roundTime(currentCheckedTime, currentCheckedTimeEnd, blockingEvent, dur
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 }
}
@ -245,3 +257,19 @@ function checkTimes(currentCheckedTime, duration, events) {
return { start: currentCheckedTime, end: currentCheckedTimeEnd, nextEvent, blockingEvent }
}
// make a function that sorts a list of objects by the "start" property
function sortEvents(events) {
// remove events that have the same start and end time, if not done causes problems
const mappedEvents = new Map()
for (const obj of events) {
const key = obj.start.toString() + obj.end.toString()
if (!mappedEvents.has(key)) {
mappedEvents.set(key, obj)
}
}
return Array.from(mappedEvents.values()).sort((a, b) => new Date(a.start) - new Date(b.start))
}

View File

@ -0,0 +1,120 @@
/**
* @copyright 2024 Grigory Vodyanov <scratchx@gmx.com>
*
* @author 2024 Grigory Vodyanov <scratchx@gmx.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { getFirstFreeSlot } from "../../../../src/services/freeBusySlotService.js";
describe('services/freeBusySlotService test suite', () => {
it('should return the first rounded slot after blocking event end', () => {
const events = [
{
start: '2024-01-01T09:00:00Z',
end: '2024-01-01T10:00:00Z',
},
]
let start = new Date('2024-01-01T08:30:00Z')
let end = new Date('2024-01-01T09:30:00Z')
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'))
})
it('should return the same amount of suggested slots as events plus one if first blocking event starts after searched time', () => {
// First blocking event starts after the searched time
const events = [
{
start: '2024-01-01T09:00:00Z',
end: '2024-01-01T10:00:00Z',
},
{
start: '2024-01-01T12:00:00Z',
end: '2024-01-01T14:00:00Z',
},
{
start: '2024-01-02T18:00:00Z',
end: '2024-01-02T19:00:00Z',
},
]
let start = new Date('2024-01-01T08:00:00Z')
let end = new Date('2024-01-01T08:45:00Z')
const result = getFirstFreeSlot(start, end, events)
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'))
})
it('should return the same amount of suggested slots as events if first blocking event conflicts with searched time', () => {
// First blocking event starts before the searched time
const events = [
{
start: '2023-12-31T09:00:00Z',
end: '2024-01-01T10:00:00Z',
},
{
start: '2024-01-01T12:00:00Z',
end: '2024-01-01T14:00:00Z',
},
{
start: '2024-01-02T18:00:00Z',
end: '2024-01-02T19:00:00Z',
},
]
let start = new Date('2024-01-01T08:00:00Z')
let end = new Date('2024-01-01T08:45:00Z')
const result = getFirstFreeSlot(start, end, events)
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'))
})
it('should not give slots between events if the difference is smaller than the searched time duration', () => {
// First blocking event starts before the searched time
const events = [
{
start: '2024-01-01T12:00:00Z',
end: '2024-01-01T14:00:00Z',
},
{
start: '2024-01-01T15:30:00Z',
end: '2024-01-01T16:00:00Z',
},
]
let start = new Date('2024-01-01T11:00:00Z')
let end = new Date('2024-01-01T12:45:00Z')
const result = getFirstFreeSlot(start, end, events)
expect(result[0].start).toEqual(new Date('2024-01-01T16:30:00Z'))
})
})