Skip to content
Snippets Groups Projects
EmissionManager.vue 26.5 KiB
Newer Older
  <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,
            v-if="resolvedScheduleRRule && resolvedScheduleRRule.count !== 1"
            v-html="
              $t('conflictResolution.recurringSchedule', {
                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>
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            </div>
            <div v-else>
              <p>{{ $t('conflictResolution.noneLeftToResolve') }}</p>
              <b-button variant="success" @click="resolveSubmit">
                {{ $t('conflictResolution.applySolution') }}
              </b-button>
              &nbsp;
              <b-button variant="danger" @click="resolveCancel">
                {{ $t('cancel') }}
              </b-button>
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            </div>
          </div>
      <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>
          :selected-day="selectedDay"
          @change-day="changeDay($event)"
          @edit-timeslot="editTimeslot($event)"
        />
      <app-modalEmissionManagerCreate
        ref="appModalEmissionManagerCreate"
        @conflict="enterConflictMode"
      />
      <app-modalEmissionManagerResolve
        ref="appModalEmissionManagerResolve"
        @resolve-conflict="resolveConflict"
      />
      <app-modalEmissionManagerEdit
        ref="appModalEmissionManagerEdit"
        @conflict="enterConflictMode"
import { addDays, parseISO } from 'date-fns'
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
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'
    PageHeader,
    FullCalendar,
    AuthWall,
    'app-modalEmissionManagerCreate': modalEmissionManagerCreate,
    'app-modalEmissionManagerResolve': modalEmissionManagerResolve,
    'app-modalEmissionManagerEdit': modalEmissionManagerEdit,
  },

      view: this.$route.query.view ?? 'week',
      adminUrl: `${import.meta.env.VUE_APP_BASEURI_STEERING}/admin`,
      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: [],
      currentStart: this.$route.query.week ? parseISO(this.$route.query.week) : new Date(),
      currentEnd: undefined,
      stopRenderWatcher: () => {
        /* noop */
      },
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
      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'],
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
        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)
        plugins: [fullCalendarTimeGridPlugin, fullCalendarInteractionPlugin],
        initialView: 'timeGridWeek',
        initialDate: this.currentStart,
        height: '100%',
        stickyHeaderDates: true,
        events: this.calendarSlots,
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
          today: this.$t('calendar.today'),
        headerToolbar: {
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
          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,
        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`,
          return start >= getClosestSlot(slotDurationMinutes)
          selectDay(selectedDate)
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
          document
            .querySelectorAll('thead .fc-day[data-date]')
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            .forEach((el) => el.classList.remove('fc-day-selected'))
          document
            .querySelector(`thead .fc-day[data-date="${getISODateString(selectedDate)}"]`)
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            .classList.add('fc-day-selected')
        },
      playlists: 'playlists/playlists',
      selectedShow: 'shows/selectedShow',
      timeslots: 'shows/timeslots',
      getPlaylistById: 'playlists/getPlaylistById',
      files: 'files/files',
      getFileById: 'files/getFileById',
    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 })
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
        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()
  },
    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
        const projectedTimeslot = event.extendedProps
        if (this.conflictSolutions[projectedTimeslot.hash] === undefined) {
          this.$refs.appModalEmissionManagerResolve.openNotNeeded()
        } else {
          this.$refs.appModalEmissionManagerResolve.open(projectedTimeslot)
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
      // standard mode only those events are clickable that belong to the
        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 }) {
        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'),
            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.extendedProps.solutionChoices = projectedTimeslot.solutionChoices
            for (const collision of projectedTimeslot.collisions) {
              const conflictingSlot = {
                id: collision.id,
                start: collision.start,
                end: collision.end,
                title: collision.showName,
                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

David Trattnig's avatar
David Trattnig committed
      // 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) {
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            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) {
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            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
              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) {
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            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
              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) {
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
            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
      // 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 {
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
        await this.$store.dispatch('shows/submitSchedule', {
          showId: this.selectedShow.id,
          schedule: resolvedSchedule,
        })
        this.conflictMode = false
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
        if (e.response?.status === 409) {
          this.enterConflictMode(e.response.data)
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
        } else {
          this.serverErrors = e.errors
        }
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
        this.submitting = false
      for (const timeslot of this.timeslots) {
        const isEmpty = !timeslot.playlistId
        let emptyText = ''
        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}`
          id: timeslot.id,
          start: timeslot.start,
          end: timeslot.end,
Konrad Mohrfeldt's avatar
Konrad Mohrfeldt committed
          className: highlighting,
          extendedProps: {
            id: timeslot.id,
            start: timeslot.start,
            end: timeslot.end,
            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),