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:
parent
4b5c8b0b69
commit
d47632eb09
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
:placeholder="placeholder"
|
||||
:class="{ 'showContent': inputGiven, 'icon-loading': isLoading }"
|
||||
:clearable="false"
|
||||
:labelOutside="true"
|
||||
input-id="uid"
|
||||
label="dropdownName"
|
||||
@search="findAttendees"
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
Loading…
Reference in New Issue