feat(scheduling): Automate free/busy slot selection

Signed-off-by: Grigory Vodyanov <scratchx@gmx.com>
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
This commit is contained in:
Grigory Vodyanov 2024-03-12 15:45:34 +01:00 committed by Christoph Wurst
parent 4b5c8b0b69
commit d47632eb09
No known key found for this signature in database
GPG Key ID: CC42AC2A7F0E56D8
5 changed files with 346 additions and 49 deletions

View File

@ -102,10 +102,26 @@
:options="options" />
<div class="modal__content__footer">
<div class="modal__content__footer__title">
<p v-if="freeSlots">
{{ $t('calendar', 'Available times:') }}
<NcSelect class="available-slots__multiselect"
:options="freeSlots"
:placeholder="placeholder"
:clearable="false"
input-id="slot"
label="displayStart"
:label-outside="true"
:value="selectedSlot"
@option:selected="setSlotSuggestion">
<template #selected-option="{}">
{{ $t('calendar', 'Suggestion accepted') }}
</template>
</NcSelect>
</p>
<h3>
{{ formattedcurrentStart }}
{{ formattedCurrentStart }}
</h3>
<p>{{ formattedCurrentTime }}<span class="modal__content__footer__title__timezone">{{ formattedTimeZoen }}</span></p>
<p>{{ formattedCurrentTime }}<span class="modal__content__footer__title__timezone">{{ formattedTimeZone }}</span></p>
</div>
<NcButton type="primary"
@ -126,7 +142,7 @@ import FullCalendar from '@fullcalendar/vue'
import resourceTimelinePlugin from '@fullcalendar/resource-timeline'
import interactionPlugin from '@fullcalendar/interaction'
import { NcDateTimePickerNative, NcButton, NcPopover, NcUserBubble, NcDialog } from '@nextcloud/vue'
import { NcDateTimePickerNative, NcButton, NcPopover, NcUserBubble, NcDialog, NcSelect } from '@nextcloud/vue'
// Import event sources
import freeBusyBlockedForAllEventSource from '../../../fullcalendar/eventSources/freeBusyBlockedForAllEventSource.js'
import freeBusyFakeBlockingEventSource from '../../../fullcalendar/eventSources/freeBusyFakeBlockingEventSource.js'
@ -152,10 +168,13 @@ 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 dateFormat from '../../../filters/dateFormat.js'
export default {
name: 'FreeBusy',
components: {
NcSelect,
FullCalendar,
InviteesListSearch,
NcDateTimePickerNative,
@ -201,13 +220,16 @@ export default {
},
eventTitle: {
type: String,
required: true,
required: false,
},
alreadyInvitedEmails: {
type: Array,
required: true,
},
calendarObjectInstance: {
type: Object,
required: true,
},
},
data() {
return {
@ -217,11 +239,15 @@ export default {
currentEnd: this.endDate,
lang: getFullCalendarLocale().locale,
formattingOptions: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' },
freeSlots: [],
selectedSlot: null,
}
},
mounted() {
const calendar = this.$refs.freeBusyFullCalendar.getApi()
calendar.scrollToTime(this.scrollTime)
this.findFreeSlots()
},
computed: {
...mapGetters({
@ -232,6 +258,9 @@ export default {
showWeekNumbers: state => state.settings.showWeekNumbers,
timezone: state => state.settings.timezone,
}),
placeholder() {
return this.$t('calendar', 'Select automatic slot')
},
/**
* FullCalendar Plugins
*
@ -245,7 +274,7 @@ export default {
interactionPlugin,
]
},
formattedcurrentStart() {
formattedCurrentStart() {
return this.currentStart.toLocaleDateString(this.lang, this.formattingOptions)
},
formattedCurrentTime() {
@ -261,7 +290,7 @@ export default {
return this.currentDate.getHours() > 0 ? new Date(this.currentDate.getTime() - 60 * 60 * 1000).toLocaleTimeString(this.lang, options) : '10:00:00'
},
formattedTimeZoen() {
formattedTimeZone() {
return this.timezoneId.replace('/', '-')
},
eventSources() {
@ -352,7 +381,7 @@ export default {
return {
// Initialization:
initialView: 'resourceTimelineDay',
initialDate: this.startDate,
initialDate: this.currentStart,
schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source',
// Data
eventSources: this.eventSources,
@ -387,6 +416,7 @@ export default {
day: 'numeric',
weekday: 'long',
},
dateClick: this.findFreeSlots(),
}
},
},
@ -400,9 +430,11 @@ export default {
},
addAttendee(attendee) {
this.$emit('add-attendee', attendee)
this.findFreeSlots()
},
removeAttendee(attendee) {
this.$emit('remove-attendee', attendee)
this.findFreeSlots()
},
loading(isLoading) {
this.loadingIndicator = isLoading
@ -425,6 +457,55 @@ export default {
}
this.currentDate = calendar.getDate()
calendar.scrollToTime(this.scrollTime)
this.findFreeSlots()
},
async findFreeSlots() {
// Doesn't make sense for multiple days
if (this.currentStart.getDate() !== this.currentEnd.getDate()) {
return
}
// Needed to update with full calendar widget changes
const startSearch = new Date(this.currentStart)
startSearch.setDate(this.currentDate.getDate())
startSearch.setMonth(this.currentDate.getMonth())
startSearch.setYear(this.currentDate.getFullYear())
const endSearch = new Date(this.currentEnd)
endSearch.setDate(this.currentDate.getDate())
endSearch.setMonth(this.currentDate.getMonth())
endSearch.setYear(this.currentDate.getFullYear())
try {
const freeSlots = await getFirstFreeSlot(
this.organizer.attendeeProperty,
this.attendees.map((a) => a.attendeeProperty),
startSearch,
endSearch,
this.timezoneId,
)
freeSlots.forEach((slot) => {
slot.displayStart = dateFormat(slot.start, false, getFullCalendarLocale().locale)
})
this.freeSlots = freeSlots
} catch (error) {
// Handle error here
console.error('Error occurred while finding free slots:', error)
throw error // Re-throwing the error to handle it in the caller
}
},
setSlotSuggestion(slot) {
this.selectedSlot = slot
const calendar = this.$refs.freeBusyFullCalendar.getApi()
calendar.gotoDate(slot.start)
calendar.scrollToTime(this.scrollTime)
// 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
},
},
}

View File

@ -76,6 +76,7 @@
:end-date="calendarObjectInstance.endDate"
:event-title="calendarObjectInstance.title"
:already-invited-emails="alreadyInvitedEmails"
:calendar-object-instance="calendarObjectInstance"
@remove-attendee="removeAttendee"
@add-attendee="addAttendee"
@update-dates="saveNewDate"

View File

@ -31,6 +31,7 @@
:placeholder="placeholder"
:class="{ 'showContent': inputGiven, 'icon-loading': isLoading }"
:clearable="false"
:labelOutside="true"
input-id="uid"
label="dropdownName"
@search="findAttendees"

View File

@ -2,6 +2,7 @@
* @copyright Copyright (c) 2019 Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author 2024 Grigory Vodyanov <scratchx@gmx.com>
*
* @license AGPL-3.0-or-later
*
@ -19,11 +20,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import getTimezoneManager from '../../services/timezoneDataProviderService.js'
import { createFreeBusyRequest, AttendeeProperty, DateTimeValue } from '@nextcloud/calendar-js'
import { findSchedulingOutbox } from '../../services/caldavService.js'
import freeBusyResourceEventSourceFunction from './freeBusyResourceEventSourceFunction.js'
import logger from '../../utils/logger.js'
import { AttendeeProperty } from '@nextcloud/calendar-js'
import { getBusySlots } from '../../services/freeBusySlotService.js'
/**
* Returns an event source for free-busy
@ -41,43 +39,12 @@ export default function(id, organizer, attendees) {
durationEditable: false,
resourceEditable: false,
events: async ({ start, end, timeZone }, successCallback, failureCallback) => {
console.debug(start, end, timeZone)
let timezoneObject = getTimezoneManager().getTimezoneForId(timeZone)
if (!timezoneObject) {
timezoneObject = getTimezoneManager().getTimezoneForId('UTC')
logger.error(`FreeBusyEventSource: Timezone ${timeZone} not found, falling back to UTC.`)
const result = await getBusySlots(organizer, attendees, start, end, timeZone)
if (result.error) {
failureCallback(result.error)
} else {
successCallback(result.events)
}
const startDateTime = DateTimeValue.fromJSDate(start, true)
const endDateTime = DateTimeValue.fromJSDate(end, true)
const organizerAsAttendee = new AttendeeProperty('ATTENDEE', organizer.email)
const freeBusyComponent = createFreeBusyRequest(startDateTime, endDateTime, organizer, [organizerAsAttendee, ...attendees])
const freeBusyICS = freeBusyComponent.toICS()
let outbox
try {
outbox = await findSchedulingOutbox()
} catch (error) {
failureCallback(error)
return
}
let freeBusyData
try {
freeBusyData = await outbox.freeBusyRequest(freeBusyICS)
} catch (error) {
failureCallback(error)
return
}
const events = []
for (const [uri, data] of Object.entries(freeBusyData)) {
events.push(...freeBusyResourceEventSourceFunction(uri, data.calendarData, data.success, startDateTime, endDateTime, timezoneObject))
}
console.debug(events)
successCallback(events)
},
}
}

View File

@ -0,0 +1,247 @@
/**
* @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 { AttendeeProperty, createFreeBusyRequest, DateTimeValue } from '@nextcloud/calendar-js'
import { findSchedulingOutbox } from './caldavService.js'
import freeBusyResourceEventSourceFunction from '../fullcalendar/eventSources/freeBusyResourceEventSourceFunction.js'
import getTimezoneManager from './timezoneDataProviderService.js'
import logger from '../utils/logger.js'
/**
* Get the first available slot for an event using 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 timeZone Timezone of the user
* @param timeZoneId
* @return {Promise<>}
*/
export async function getBusySlots(organizer, attendees, start, end, timeZoneId) {
let timezoneObject = getTimezoneManager().getTimezoneForId(timeZoneId)
if (!timezoneObject) {
timezoneObject = getTimezoneManager().getTimezoneForId('UTC')
logger.error(`FreeBusyEventSource: Timezone ${timeZoneId} not found, falling back to UTC.`)
}
const startDateTime = DateTimeValue.fromJSDate(start, true)
const endDateTime = DateTimeValue.fromJSDate(end, true)
const organizerAsAttendee = new AttendeeProperty('ATTENDEE', organizer.email)
const freeBusyComponent = createFreeBusyRequest(startDateTime, endDateTime, organizer, [organizerAsAttendee, ...attendees])
const freeBusyICS = freeBusyComponent.toICS()
let outbox
try {
outbox = await findSchedulingOutbox()
} catch (error) {
return { error }
}
let freeBusyData
try {
freeBusyData = await outbox.freeBusyRequest(freeBusyICS)
} 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))
}
return { events }
}
/**
* Get the first available slot for an event using 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<[]>}
*/
export async function getFirstFreeSlot(organizer, attendees, start, end, timeZoneId) {
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)
if (eventResults.error) {
return [{ error: eventResults.error }]
}
const events = eventResults.events
let currentCheckedTime = start
const currentCheckedTimeEnd = new Date(currentCheckedTime)
currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration)
const foundSlots = []
// more than 1 suggestions is too much
// todo: make it 5
for (let i = 0; (i < events.length + 1 && i < 1); i++) {
foundSlots[i] = checkTimes(currentCheckedTime, 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.pop()
break
}
}
foundSlots.forEach((slot, index) => {
const roundedTime = roundTime(slot.start, slot.end, slot.blockingEvent, duration)
foundSlots[index].start = roundedTime.start
foundSlots[index].end = roundedTime.end
// not needed anymore
foundSlots[index].nextEvent = undefined
})
return foundSlots
}
/**
*
* @param start
* @param end
*/
function getDurationInSeconds(start, end) {
// 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
return Math.floor(durationMs / 1000)
}
/**
*
* @param currentCheckedTime
* @param currentCheckedTimeEnd
* @param blockingEvent
* @param duration
*/
function roundTime(currentCheckedTime, currentCheckedTimeEnd, blockingEvent, duration) {
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)
return { start: currentCheckedTime, end: currentCheckedTimeEnd }
}
/**
*
* @param currentCheckedTime
* @param duration
* @param events
*/
function checkTimes(currentCheckedTime, duration, events) {
let slotIsBusy = false
let blockingEvent = null
let nextEvent = null
let currentCheckedTimeEnd = new Date(currentCheckedTime)
currentCheckedTimeEnd.setSeconds(currentCheckedTime.getSeconds() + duration)
// 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
}
if (blockingEvent !== null) {
const blockingIndex = events.findIndex((event) => event === blockingEvent)
nextEvent = events[blockingIndex + 1]
} else {
if (events.length > 0) nextEvent = events[0]
}
return { start: currentCheckedTime, end: currentCheckedTimeEnd, nextEvent, blockingEvent }
}