<template> <b-container> <template v-if="selectedShow"> <auth-wall> <show-selector ref="showSelector" :title="$t('navigation.calendar')" :calendar="true" :callback="showHasSwitched" @set-view="changeView" /> <hr /> <b-alert :variant="conflictCount > 0 ? 'danger' : 'success'" :show="conflictMode"> <div v-if="conflictMode"> <h4>{{ $t('conflictResolution.title') }}</h4> <p :class="{ 'tw-mb-4': resolveData.schedule.rrule === 1, 'tw-mb-0': resolveData.schedule.rrule !== 1, }" v-html=" $t('conflictResolution.newSchedule', { firstDate: prettyDate(resolveData.schedule.first_date), startTime: resolveData.schedule.start_time, endTime: resolveData.schedule.end_time, }) " /> <p v-if="resolveData.schedule.rrule !== 1" v-html=" $t('conflictResolution.recurringSchedule', { rrule: rruleRender(resolveData.schedule.rrule), lastDate: prettyDate(resolveData.schedule.last_date), }) " /> <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 :errors="serverErrors" /> <div> <div :class="{ 'tw-hidden': view !== 'week' }"> <FullCalendar ref="calendar" :options="calendarConfig" /> </div> <div :class="{ 'schedule-panel tw-w-full': true, 'tw-hidden': view !== 'day', }" > <div class="tw-flex tw-items-center tw-justify-between tw-mb-4"> <h3>{{ prettyDate(selectedDay) }}</h3> <b-button-group> <b-button type="info" @click="changeDay(-1)"> <svg class="tw-w-4 tw-h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" > <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> </svg> </b-button> <b-button type="info" @click="changeDay(1)"> <svg class="tw-w-4 tw-h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" > <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> </svg> </b-button> </b-button-group> </div> <div v-for="timeslot in timeslotsForDay" :key="timeslot.id" :class="{ 'timeslot tw-w-full tw-py-2 tw-px-3 tw-rounded hover:tw-bg-gray-200 hover:tw-text-gray-900 tw-cursor-pointer tw-border tw-border-solid tw-border-gray-200 tw-mb-2': true, 'tw-bg-gray-900 tw-text-white': new Date() >= new Date(timeslot.start) && new Date() <= new Date(timeslot.end), 'tw-bg-gray-200 tw-text-gray-600 tw-opacity-75': new Date() >= new Date(timeslot.end), }" @click="() => timeslotClicked(timeslot)" > <div class="tw-flex tw-justify-between tw-items-center"> <div> <p class="tw-mb-0 tw-leading-tight tw-font-bold"> {{ timeslot.show.name }} </p> <span class="tw-text-sm"> {{ prettyTime(timeslot.start) }} - {{ prettyTime(timeslot.end) }} <span v-if="timeslot.is_repetition" class="tw-text-gray-400">{{ $t('calendar.repetition') }}</span> </span> </div> <div v-if="loaded.playlists"> <div v-if="!timeslot.playlist_id"> <span v-if="timeslot.show.default_playlist_id" class="tw-leading-none"> <span class="tw-block" ><strong>{{ $t('emissionTable.playlist') }}:</strong> {{ getPlaylistById(timeslot.show.default_playlist_id).description || timeslot.show.default_playlist_id }}</span > <span class="tw-text-xs tw-text-red-500">{{ $t('calendar.fallback') }}</span> </span> <span v-else> {{ $t('calendar.empty') }} </span> </div> <div v-else class="tw-leading-none"> <span class="tw-block" ><strong>{{ $t('emissionTable.playlist') }}:</strong> {{ timeslot.playlist.description || timeslot.playlist_id }}</span > <span class="tw-text-sm"> <strong>{{ $t('emissionTable.duration') }}:</strong> {{ playlistDuration(timeslot.playlist) }} <span v-if="isMismatchedLength(timeslot.playlist, timeslot)" class="is-mismatched" > {{ $t('calendar.mismatchedLength') }} </span> </span> </div> </div> </div> </div> </div> </div> </auth-wall> <app-modalEmissionManagerCreate ref="appModalEmissionManagerCreate" @conflict="enterConflictMode" /> <app-modalEmissionManagerResolve ref="appModalEmissionManagerResolve" @resolve-conflict="resolveConflict" /> <app-modalEmissionManagerEdit ref="appModalEmissionManagerEdit" @conflict="enterConflictMode" /> </template> <div v-else-if="loaded.shows && !selectedShow" class="tw-text-center" v-html="$t('noAssignedShows', { adminUrl })" /> </b-container> </template> <script> import { mapGetters } from 'vuex' // We need the @fullcalendar/core/vdom import because it has side-effects without which // Vite would throw an error when loading the FullCalendar vue component. import '@fullcalendar/core/vdom' import FullCalendar from '@fullcalendar/vue3' import fullCalendarTimeGridPlugin from '@fullcalendar/timegrid' import fullCalendarInteractionPlugin from '@fullcalendar/interaction' import showSelector from '@/components/ShowSelector.vue' 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 rrules from '@/mixins/rrules' import prettyDate from '@/mixins/prettyDate' import playlist from '@/mixins/playlist' import ServerErrors from '@/components/ServerErrors.vue' import { getISODateString } from '@/utilities' export default { components: { ServerErrors, FullCalendar, AuthWall, 'show-selector': showSelector, 'app-modalEmissionManagerCreate': modalEmissionManagerCreate, 'app-modalEmissionManagerResolve': modalEmissionManagerResolve, 'app-modalEmissionManagerEdit': modalEmissionManagerEdit, }, mixins: [rrules, prettyDate, playlist], data() { return { view: 'week', adminUrl: `${import.meta.env.VUE_APP_BASEURI_STEERING}/admin`, calendarSlots: [], selectedDay: 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: undefined, currentEnd: undefined, stopRenderWatcher: () => {}, } }, computed: { ...mapGetters({ getShow: 'shows/getShowByDataParam', }), loaded() { return { shows: this.$store.state.shows.loaded['shows'], timeslots: this.$store.state.shows.loaded['timeslots'], playlists: this.$store.state.playlists.loaded['playlists'], } }, timeslotsForDay() { return this.timeslots .filter( (timeslot) => getISODateString(new Date(timeslot.start)) === getISODateString(this.selectedDay), ) .map((timeslot) => { const id = timeslot.show const show = this.getShow({ id }) return { ...timeslot, playlist: timeslot.playlist_id ? this.getPlaylistById(timeslot.playlist_id) : null, show: { ...show, }, } }) }, // this is the whole configuration for our schedule calendar, including // simple event handlers that do not need the whole components scope calendarConfig() { const selectDay = this.selectDay.bind(this) return { plugins: [fullCalendarTimeGridPlugin, fullCalendarInteractionPlugin], initialView: 'timeGridWeek', locale: this.$activeLocale(), height: 600, 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, datesSet: (view) => { if ( this.currentStart?.toISOString?.() !== view.start.toISOString() || this.currentEnd?.toISOString?.() !== view.end.toISOString() ) { this.currentStart = view.start this.currentEnd = view.end } }, eventDidMount({ event, el }) { // here we add a simple tooltip to every event, so that the full title // of a show can be viewed el.setAttribute('title', event.title) }, eventClick: this.eventSelected, select: this.createEvent, selectable: true, selectMirror: true, 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', }), }, created() { this.$store.dispatch('shows/fetchShows', { callback: () => { if (!this.selectedShow) { return } this.$nextTick(() => { this.showHasSwitched() this.$refs.showSelector.updateInputSelector() }) }, }) }, 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( getISODateString(this.currentStart), getISODateString(this.currentEnd), ) } } }, ) }, unmounted() { this.stopRenderWatcher() }, methods: { changeView(view) { this.view = view }, changeDay(delta) { this.selectedDay = new Date(this.selectedDay.setDate(this.selectedDay.getDate() + delta)) }, selectDay(date) { this.selectedDay = date }, switchShow(index) { this.$store.commit('shows/switchShow', index) this.loadCalendarSlots() }, // This is the callback function that is activated by the show-selector // component, whenever the user switches to a different show showHasSwitched() { this.$store.dispatch('playlists/fetch', { slug: this.selectedShow.slug, callback: () => console.log(this.playlists), }) this.$log.debug('show has switched to', this.selectedShow.name) this.loadTimeslots(getISODateString(this.currentStart), getISODateString(this.currentEnd)) }, 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 } }, timeslotClicked(slot) { const timeslot = { ...slot } timeslot.show = slot.show.id if (timeslot.show !== this.selectedShow.id) { this.switchShow(this.getShowIndexById(timeslot.show)) } else { 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.show !== this.selectedShow.id) { this.switchShow(this.getShowIndexById(timeslot.show)) } 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, 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.solution_choices for (const collision of projectedTimeslot.collisions) { const conflictingSlot = { id: collision.id, start: collision.start, end: collision.end, title: collision.show_name, className: 'otherShow', editable: false, extendedProps: { id: collision.id, start: collision.start, end: collision.end, title: collision.show_name, }, } 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.add_business_days_only === undefined) { this.resolveData.schedule.add_business_days_only = false } if (this.resolveData.schedule.add_days_no === null) { this.resolveData.schedule.add_days_no = 0 } if (this.resolveData.schedule.is_repetition === undefined) { this.resolveData.schedule.is_repetition = false } if (this.resolveData.schedule.default_playlist_id === null) { this.resolveData.schedule.default_playlist_id = 0 } if (this.resolveData.schedule.automation_id === null) { this.resolveData.schedule.automation_id = 0 } if (this.resolveData.schedule.by_weekday === undefined) { this.resolveData.schedule.by_weekday = 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.playlist_id let emptyText = '' let highlighting = 'otherShow' if (timeslot.show === this.selectedShow.id) { highlighting = 'currentShow ' highlighting += isEmpty ? 'emptySlot' : '' emptyText = isEmpty ? this.$t('calendar.empty') : '' } this.calendarSlots.push({ id: timeslot.id, start: timeslot.start, end: timeslot.end, title: this.getShowTitleById(timeslot.show) + `\n${emptyText}`, className: highlighting, extendedProps: { id: timeslot.id, start: timeslot.start, end: timeslot.end, title: this.getShowTitleById(timeslot.show) + `\n${emptyText}`, }, }) } }, loadTimeslots(start, end) { this.$store.dispatch('shows/fetchTimeslots', { start: start, end: end, callback: () => { this.loadCalendarSlots() }, }) }, isMismatchedLength(playlist, timeslot) { const timeslotDuration = this.minutesToNanoseconds( this.prettyDuration(timeslot.start, timeslot.end).minutes, ) const playlistDuration = this.hmsToNanoseconds(this.playlistDuration(playlist)) let delta = 0 const unknowns = playlist.entries.filter((entry) => !entry.duration) if (unknowns.length === 1) { delta = timeslotDuration - playlistDuration } console.log(timeslotDuration, playlistDuration + delta) return timeslotDuration !== playlistDuration + delta }, notYetImplemented: function () { alert(this.$t('unimplemented')) }, }, } </script>