mirror of https://github.com/nextcloud/calendar
Enh/automatic free slot finding and add tests
Signed-off-by: Grigory Vodyanov <scratchx@gmx.com>
This commit is contained in:
parent
6ea485f3fc
commit
6b4845ac8d
|
@ -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))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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'))
|
||||
})
|
||||
|
||||
})
|
Loading…
Reference in New Issue