<template> <div class="tw-flex tw-flex-col tw-h-full"> <PageHeader :title="$t('navigation.calendar')"> <b-button-group v-if="selectedShow"> <b-button :variant="view === 'day' ? 'primary' : 'secondary'" @click="view = 'day'"> {{ $t('calendar.view.day') }} </b-button> <b-button :variant="view === 'week' ? 'primary' : 'secondary'" @click="view = 'week'"> {{ $t('calendar.view.week') }} </b-button> </b-button-group> </PageHeader> <template v-if="!loaded.shows"> <div class="tw-text-center"> {{ $t('loading') }} </div> </template> <auth-wall v-else-if="selectedShow" class="tw-flex-1 tw-flex tw-flex-col"> <b-alert class="tw-flex-none" :variant="conflictCount > 0 ? 'danger' : 'success'" :show="conflictMode" > <div v-if="conflictMode"> <h4>{{ $t('conflictResolution.title') }}</h4> <p :class="{ 'tw-mb-4': resolvedScheduleRRule?.count === 1, 'tw-mb-0': resolvedScheduleRRule?.count !== 1, }" v-html=" $t('conflictResolution.newSchedule', { firstDate: prettyDate(resolveData.schedule.firstDate), startTime: resolveData.schedule.startTime, endTime: resolveData.schedule.endTime, }) " /> <p v-if="resolvedScheduleRRule && resolvedScheduleRRule.count !== 1" v-html=" $t('conflictResolution.recurringSchedule', { rrule: resolvedScheduleRRule.name, lastDate: prettyDate(resolveData.schedule.lastDate), }) " /> <div v-if="submitting"> <b-row> <b-col align="center"> <img src="/assets/radio.gif" :alt="$t('loading')" /> </b-col> </b-row> </div> <div v-else> <div v-if="conflictCount > 0"> <p>{{ $t('conflictResolution.leftToResolve', { smart_count: conflictCount }) }}</p> <b-button variant="danger" size="sm" @click="resolveCancel"> {{ $t('cancel') }} </b-button> </div> <div v-else> <p>{{ $t('conflictResolution.noneLeftToResolve') }}</p> <b-button variant="success" @click="resolveSubmit"> {{ $t('conflictResolution.applySolution') }} </b-button> <b-button variant="danger" @click="resolveCancel"> {{ $t('cancel') }} </b-button> </div> </div> </div> </b-alert> <server-errors class="tw-flex-none" :errors="serverErrors" /> <div class="tw-flex-1 tw-basis-full"> <KeepAlive> <div v-if="view === 'week'" class="tw-h-full"> <FullCalendar ref="calendar" :options="calendarConfig" /> </div> </KeepAlive> <CalendarDayView v-if="view === 'day'" :selected-day="selectedDay" @change-day="changeDay($event)" @edit-timeslot="editTimeslot($event)" /> </div> <app-modalEmissionManagerCreate ref="appModalEmissionManagerCreate" @conflict="enterConflictMode" @update="loadTimeslots()" /> <app-modalEmissionManagerResolve ref="appModalEmissionManagerResolve" @resolve-conflict="resolveConflict" /> <app-modalEmissionManagerEdit ref="appModalEmissionManagerEdit" @conflict="enterConflictMode" @update="loadTimeslots()" /> </auth-wall> </div> </template> <script> import { addDays, parseISO } from 'date-fns' import { h } from 'vue' import { mapGetters } from 'vuex' import FullCalendar from '@fullcalendar/vue3' import fullCalendarTimeGridPlugin from '@fullcalendar/timegrid' import fullCalendarInteractionPlugin from '@fullcalendar/interaction' import modalEmissionManagerCreate from '@/components/emissions/ModalCreate.vue' import modalEmissionManagerResolve from '@/components/emissions/ModalResolve.vue' import modalEmissionManagerEdit from '@/components/emissions/ModalEdit.vue' import AuthWall from '@/components/AuthWall.vue' import prettyDate from '@/mixins/prettyDate' import playlist from '@/mixins/playlist' import ServerErrors from '@/components/ServerErrors.vue' import { getISODateString } from '@/utilities' import PageHeader from '@/components/PageHeader.vue' import { calculateDurationSeconds, getClosestSlot, getNextAvailableSlot, sanitizeHTML, } from '@/util' import CalendarDayView from '@/components/CalendarDayView.vue' import { mapStores } from 'pinia' import { useRRuleStore } from '@/stores/rrules' export default { components: { CalendarDayView, PageHeader, ServerErrors, FullCalendar, AuthWall, 'app-modalEmissionManagerCreate': modalEmissionManagerCreate, 'app-modalEmissionManagerResolve': modalEmissionManagerResolve, 'app-modalEmissionManagerEdit': modalEmissionManagerEdit, }, mixins: [prettyDate, playlist], data() { return { view: this.$route.query.view ?? 'week', adminUrl: `${import.meta.env.VUE_APP_BASEURI_STEERING}/admin`, calendarSlots: [], selectedDay: this.$route.query.day ? parseISO(this.$route.query.day) : new Date(), // flag for when submitting resolve data submitting: false, // this flag signifies if we are in conflict resolution mode conflictMode: false, // when conflict mode is activated, this should hold the steering response // from schedule creation, with all the conflicts and solutions resolveData: null, conflictCount: 0, conflictSolutions: [], serverErrors: [], currentStart: this.$route.query.week ? parseISO(this.$route.query.week) : new Date(), currentEnd: undefined, stopRenderWatcher: () => { /* noop */ }, } }, computed: { ...mapGetters({ getShow: 'shows/getShowByDataParam', }), ...mapStores(useRRuleStore), resolvedScheduleRRule() { return this.rrulesStore.itemMap.get(this.resolveData.schedule.rruleId) }, loaded() { return { shows: this.$store.state.shows.loaded['shows'], timeslots: this.$store.state.shows.loaded['timeslots'], playlists: this.$store.state.playlists.loaded['playlists'], } }, /** * this is the whole configuration for our schedule calendar, including * simple event handlers that do not need the whole components scope * @returns {CalendarOptions} */ calendarConfig() { const selectDay = this.selectDay.bind(this) const slotDurationMinutes = 15 return { plugins: [fullCalendarTimeGridPlugin, fullCalendarInteractionPlugin], initialView: 'timeGridWeek', locale: this.$activeLocale(), initialDate: this.currentStart, height: '100%', stickyHeaderDates: true, firstDay: 1, navLinks: true, events: this.calendarSlots, buttonText: { today: this.$t('calendar.today'), }, headerToolbar: { left: 'title', center: '', right: 'today prev,next', }, dayHeaderFormat: { day: 'numeric', month: 'numeric', weekday: 'short' }, eventTimeFormat: { hour: 'numeric', minute: '2-digit' }, slotLabelFormat: { hour: 'numeric', minute: '2-digit' }, allDaySlot: false, editable: false, nowIndicator: true, eventContent({ event, timeText }) { // The eventContent function doesn’t quite seem to work like documented in the fullcalendar docs: // * the slot is broken because it doesn’t receive a context object ('arg' is undefined) // * the Preact createElement/h function that is passed as the second argument to this function // doesn’t render anything // * returning { html: '<i>hello</i>' } doesn’t render anything // * returning { domNodes: [(() => { const i = document.createElement('i'); i.textContent = 'hello'; return i })()] } // doesn’t render anything // // Instead, this comment [1] mentions that one should use the Vue createElement/h function. // Surprisingly this works. // // It is unclear to me why all these (documented) options fail. One major difference is that we run Vue 3 in // Vue 2 compat mode which might be a source of errors. // // [1]: https://github.com/fullcalendar/fullcalendar/issues/7175#issuecomment-1409519357 const { durationMinutes, title } = event.extendedProps // don’t render any content if it would be crammed anyway const content = durationMinutes > slotDurationMinutes ? [ h('div', { class: 'fc-event-time' }, timeText), h('div', { class: 'fc-event-title-container' }, [ h('div', { class: 'fc-event-title fc-sticky' }, title), ]), ] : [] return h('div', { class: 'fc-event-main-frame' }, content) }, eventDidMount({ el, event, timeText }) { const { durationMinutes } = event.extendedProps let { title } = event.extendedProps if (durationMinutes < slotDurationMinutes) { title = `${timeText}: ${title}` } // here we add a simple tooltip to every event, so that the full title // of a show can be viewed el.setAttribute('title', title) }, datesSet: (view) => { if ( this.currentStart?.toISOString?.() !== view.start.toISOString() || this.currentEnd?.toISOString?.() !== view.end.toISOString() ) { this.$router.replace({ name: this.$route.name, params: { ...this.$route.params }, query: { ...this.$route.query, view: this.view, day: getISODateString(this.selectedDay), week: getISODateString(view.start), }, }) this.currentStart = view.start this.currentEnd = view.end } }, eventClick: this.eventSelected, select: this.createEvent, selectable: true, selectMirror: true, slotDuration: `00:${slotDurationMinutes.toString().padStart(2, '0')}:00`, eventMinHeight: 1, selectAllow({ start }) { return start >= getClosestSlot(slotDurationMinutes) }, navLinkDayClick(selectedDate) { selectDay(selectedDate) document .querySelectorAll('thead .fc-day[data-date]') .forEach((el) => el.classList.remove('fc-day-selected')) document .querySelector(`thead .fc-day[data-date="${getISODateString(selectedDate)}"]`) .classList.add('fc-day-selected') }, } }, ...mapGetters({ shows: 'shows/shows', playlists: 'playlists/playlists', selectedShow: 'shows/selectedShow', timeslots: 'shows/timeslots', getPlaylistById: 'playlists/getPlaylistById', files: 'files/files', getFileById: 'files/getFileById', }), }, watch: { view(newView) { void this.$router.replace({ name: this.$route.name, params: { ...this.$route.params }, query: { ...this.$route.query, view: newView }, }) }, selectedDay(newDate) { void this.$router.replace({ name: this.$route.name, params: { ...this.$route.params }, query: { ...this.$route.query, day: getISODateString(newDate) }, }) }, selectedShow: { immediate: true, handler(newShow) { if (newShow) { this.$store.dispatch('playlists/fetch', { showSlug: newShow.slug }) this.loadTimeslots() } }, }, }, mounted() { this.$nextTick(() => { try { document .querySelectorAll('.fc-day-header[data-date]') .forEach((el) => el.classList.remove('fc-day-selected')) document .querySelector(`.fc-day-header[data-date="${getISODateString()}"]`) .classList.add('fc-day-selected') } catch (e) { // Full Calendar might not be initialized yet. } }) // Watches all relevant properties for timeslot updates // and executes the timeslot updater when a change is detected. // Array joining in the source is done because arrays are compared by identity in JavaScript. // Also… this would look so much nicer as a watchEffect(() => { ... }) // but would obviously require a Composition-API rewrite. this.stopRenderWatcher = this.$watch( (em) => [ em.loaded.shows, em.loaded.playlists, em.conflictMode, em.currentStart?.toISOString?.(), em.currentEnd?.toISOString?.(), ].join('-'), () => { // this is called when the user changes the calendar view, so we just // re-fetch the timeslots with the updated visible date range if (this.loaded.shows && this.loaded.playlists) { // we only load new timeslots, if we are not in conflict mode if (!this.conflictMode) { this.loadTimeslots() } } }, ) }, unmounted() { this.stopRenderWatcher() }, methods: { changeDay(delta) { this.selectedDay = addDays(this.selectedDay, delta) }, selectDay(date) { this.selectedDay = date }, switchShow(index) { this.$store.commit('shows/switchShow', index) this.loadCalendarSlots() }, getShowTitleById(id) { const i = this.shows.findIndex((show) => show.id === id) if (i >= 0) { return this.shows[i].name } else { return 'Error: no show found for this timeslot' } }, getShowIndexById(id) { const i = this.shows.findIndex((show) => show.id === id) if (i >= 0) { return i } else { this.$log.error('No show found for id ' + id) return 0 } }, editTimeslot(timeslot) { this.$refs.appModalEmissionManagerEdit.open(timeslot) }, // this handler will be called whenever the user clicks on one of the // displayed timeslots eventSelected({ event }) { // in conflict resolution mode only the newly generated events are // clickable. if there is no conflict for an event, only a modal // with a short notice should be opened, otherwise the resolution modal if (this.conflictMode) { const projectedTimeslot = event.extendedProps if (this.conflictSolutions[projectedTimeslot.hash] === undefined) { this.$refs.appModalEmissionManagerResolve.openNotNeeded() } else { this.$refs.appModalEmissionManagerResolve.open(projectedTimeslot) } } // standard mode only those events are clickable that belong to the // currently selected show. else { const selectedTimeslotId = event.extendedProps.id const timeslot = this.timeslots.find((slot) => slot.id === selectedTimeslotId) if (timeslot.showId !== this.selectedShow.id) { this.switchShow(this.getShowIndexById(timeslot.showId)) } else { this.$refs.appModalEmissionManagerEdit.open(timeslot) } } }, // this handler is called when the user creates a new timeslot createEvent({ start, end }) { if (!this.conflictMode) { this.$refs.appModalEmissionManagerCreate.open( start < new Date() ? getNextAvailableSlot(5) : start, end, ) } }, enterConflictMode(data) { this.resolveData = data this.conflictMode = true this.conflictCount = 0 this.conflictSolutions = data.solutions this.calendarSlots = [] try { for (const projectedTimeslot of data.projected) { // we need a numeric ID for the event for later selection by the user. // with converting the hash to a number (in this case a float), we // do not risk using a number that is already used by a timeslot id // of a conflicting timeslot const id = Number(projectedTimeslot.hash) const newSlot = { id, start: projectedTimeslot.start, end: projectedTimeslot.end, title: this.$t('conflictResolution.conflictingSlot'), className: 'noconflict', editable: false, extendedProps: { id, // the hash is needed to compare against solutions and conflicts hash: projectedTimeslot.hash, start: projectedTimeslot.start, end: projectedTimeslot.end, collisions: [], solutionChoices: [], title: this.$t('conflictResolution.conflictingSlot'), }, } if (projectedTimeslot.collisions.length > 0) { newSlot.className = 'conflict' newSlot.extendedProps.solutionChoices = projectedTimeslot.solutionChoices for (const collision of projectedTimeslot.collisions) { const conflictingSlot = { id: collision.id, start: collision.start, end: collision.end, title: collision.showName, className: 'otherShow', editable: false, extendedProps: { id: collision.id, start: collision.start, end: collision.end, title: collision.showName, }, } this.calendarSlots.push(conflictingSlot) this.conflictCount++ newSlot.extendedProps.collisions.push(collision) } } this.calendarSlots.push(newSlot) } } catch (err) { this.$log.error(err) } }, resolveConflict(toResolve, mode) { const calendarSlot = this.calendarSlots.find((s) => s.id === toResolve.id) const originalSlot = this.resolveData.projected.find((s) => s.hash === toResolve.hash) // we only need the conflicting slot specifically for theirs-both mode, where there should be only one collision const conflictingSlot = this.calendarSlots.find((s) => s.id === toResolve.collisions[0].id) // we only reduce the conflict count, if there was no other solution set already if (this.conflictSolutions[toResolve.hash] === '') { this.conflictCount -= toResolve.collisions.length } // if there already was a resolution chosen before, that added a second timeslot // because either the "ours" or "theirs" was split, we have to clean it up before // in the calendar before setting a new resolution const oldResolutionSlot = this.calendarSlots.findIndex((s) => s.id === calendarSlot.id * 10) if (oldResolutionSlot > -1) { this.calendarSlots.splice(oldResolutionSlot, 1) } // and in case their was a ours-* choice before, we have to reset the (one) // conflicting slot to its original start and end time conflictingSlot.start = toResolve.collisions[0].start conflictingSlot.end = toResolve.collisions[0].end // for a detailed description of the resolution modes, see https://docs.aura.radio/en/latest/user/timeslot-collision-detection.html // and https://docs.aura.radio/en/latest/developer/misc/conflict-resolution.html switch (mode) { case 'theirs': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-discarded' calendarSlot.title = this.$t('conflictResolution.conflictingSlot') calendarSlot.start = originalSlot.start calendarSlot.end = originalSlot.end for (const theirs of toResolve.collisions) { this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-accepted' } break case 'ours': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-accepted' calendarSlot.title = this.$t('conflictResolution.conflictingSlot') calendarSlot.start = originalSlot.start calendarSlot.end = originalSlot.end for (const theirs of toResolve.collisions) { this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-discarded' } break case 'theirs-start': case 'theirs-end': case 'theirs-both': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-partly' calendarSlot.title = 'new [' + mode + ']' if (mode === 'theirs-start') { calendarSlot.start = toResolve.collisions[0].end calendarSlot.end = originalSlot.end } else if (mode === 'theirs-end') { calendarSlot.start = originalSlot.start calendarSlot.end = toResolve.collisions[0].start } else { calendarSlot.start = originalSlot.start calendarSlot.end = toResolve.collisions[0].start // TODO: that id generation seems __very__ weird const id = calendarSlot.id * 10 this.calendarSlots.push({ id, start: toResolve.collisions[0].end, end: originalSlot.end, title: 'new [theirs-both]', className: 'timeslot-partly', editable: false, extendedProps: { id, start: toResolve.collisions[0].end, end: originalSlot.end, title: 'new [theirs-both]', }, }) } for (const theirs of toResolve.collisions) { this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-accepted' } break case 'ours-start': case 'ours-end': case 'ours-both': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-accepted' calendarSlot.title = 'new [' + mode + ']' if (mode === 'ours-start') { conflictingSlot.start = toResolve.collisions[0].start conflictingSlot.end = originalSlot.start } else if (mode === 'ours-end') { conflictingSlot.start = originalSlot.end conflictingSlot.end = toResolve.collisions[0].end } else { conflictingSlot.start = toResolve.collisions[0].start conflictingSlot.end = originalSlot.start // TODO: that id generation seems __very__ weird const id = calendarSlot.id * 10 this.calendarSlots.push({ id, start: originalSlot.end, end: toResolve.collisions[0].end, title: conflictingSlot.title, className: 'timeslot-partly', editable: false, extendedProps: { id, start: originalSlot.end, end: toResolve.collisions[0].end, title: conflictingSlot.title, }, }) } for (const theirs of toResolve.collisions) { this.calendarSlots.find((s) => s.id === theirs.id).className = 'timeslot-partly' } break default: this.$log.error('EmissionManager.resolveEvent') this.$log.error('toResolve:', toResolve) this.$log.error('mode:', mode) alert('Error: an undefined conflict resolution mode was chosen. See console for details') break } }, resolveCancel() { this.conflictMode = false }, // submit a conflict-resolved schedule to steering async resolveSubmit() { // TODO: check why steering returns undefined and null values here if (this.resolveData.schedule.addBusinessDaysOnly === undefined) { this.resolveData.schedule.addBusinessDaysOnly = false } if (this.resolveData.schedule.addDaysNo === null) { this.resolveData.schedule.addDaysNo = 0 } if (this.resolveData.schedule.isRepetition === undefined) { this.resolveData.schedule.isRepetition = false } if (this.resolveData.schedule.defaultPlaylistId === null) { this.resolveData.schedule.defaultPlaylistId = 0 } if (this.resolveData.schedule.automationId === null) { this.resolveData.schedule.automationId = 0 } if (this.resolveData.schedule.byWeekday === undefined) { this.resolveData.schedule.byWeekday = 0 } // create the resolved schedule object including solutions const resolvedSchedule = { schedule: this.resolveData.schedule, solutions: this.resolveData.solutions, } this.$log.debug('resolveSubmit: schedule:', resolvedSchedule) this.submitting = true this.serverErrors = [] try { await this.$store.dispatch('shows/submitSchedule', { showId: this.selectedShow.id, schedule: resolvedSchedule, }) this.conflictMode = false } catch (e) { if (e.response?.status === 409) { this.enterConflictMode(e.response.data) } else { this.serverErrors = e.errors } } finally { this.submitting = false } }, loadCalendarSlots() { this.calendarSlots = [] for (const timeslot of this.timeslots) { const isEmpty = !timeslot.playlistId let emptyText = '' let highlighting = 'otherShow' if (timeslot.showId === this.selectedShow.id) { highlighting = 'currentShow ' highlighting += isEmpty ? 'emptySlot' : '' emptyText = isEmpty ? this.$t('calendar.empty') : '' } const title = sanitizeHTML(this.getShowTitleById(timeslot.showId)) + `\n${emptyText}` this.calendarSlots.push({ id: timeslot.id, start: timeslot.start, end: timeslot.end, title, className: highlighting, extendedProps: { id: timeslot.id, start: timeslot.start, end: timeslot.end, title, durationMinutes: calculateDurationSeconds(parseISO(timeslot.start), parseISO(timeslot.end)) / 60, }, }) } }, loadTimeslots(start, end) { this.$store.dispatch('shows/fetchTimeslots', { start: getISODateString(start ?? this.currentStart), end: getISODateString(end ?? this.currentEnd), callback: () => { this.loadCalendarSlots() }, }) }, }, } </script>