Skip to content
Snippets Groups Projects
CalendarDayView.vue 11.5 KiB
Newer Older
  • Learn to ignore specific revisions
  •   <div>
        <div class="schedule-panel tw-w-full">
          <div class="tw-flex tw-items-center tw-justify-between tw-mb-4">
            <h3>{{ selectedDay.toLocaleString(locale, { dateStyle: 'long' }) }}</h3>
    
            <div class="fc fc-direction-ltr">
              <div class="fc-button-group">
                <button
                  type="button"
                  class="fc-button fc-prev-button fc-button-primary"
                  @click="emit('changeDay', -1)"
    
                  <span class="fc-icon fc-icon-chevron-left"></span>
                </button>
    
                <button
                  type="button"
                  class="fc-button fc-next-button fc-button-primary"
                  @click="emit('changeDay', 1)"
    
                  <span class="fc-icon fc-icon-chevron-right"></span>
                </button>
              </div>
    
    
          <table class="slot-table tw-block md:tw-table" role="table">
            <colgroup>
              <col width="1%" />
            </colgroup>
            <tbody role="rowgroup" class="tw-block md:tw-table-row-group">
              <template v-for="(slot, index) in slots" :key="index">
                <tr v-if="index !== 0" role="presentation" class="tw-block md:tw-table-row">
                  <td colspan="2" class="tw-p-0 tw-block md:tw-table-cell tw-w-full">
                    <hr class="tw-my-4" />
                  </td>
                </tr>
                <tr
                  class="slot tw-block md:tw-table-row tw-w-full tw-relative"
                  role="row"
                  :class="{
                    'slot--today': nowDateString === selectedDayISODate,
                    'slot--past': now > slot.end,
                    'slot--current': now > slot.start && now < slot.end,
                    'slot--future': now < slot.start,
                  }"
                >
                  <th
                    class="slot-time tw-block md:tw-table-cell tw-align-top tw-font-normal tw-text-gray-600"
                    scope="row"
                    role="rowheader"
                  >
                    <span v-if="now > slot.start && now < slot.end" class="slot-time-indicator">
                      <span
                        :style="{
                          top: `${mapToDomain(
                            now.getTime(),
                            [slot.start.getTime(), slot.end.getTime()],
                            [0, 100],
                          )}%`,
                        }"
                      />
                    </span>
    
                    <span class="tw-whitespace-nowrap">
                      {{ formatTime(slot.start) }} - {{ formatTime(slot.end) }}
                    </span>
                    <br />
                    <span class="tw-text-sm">
                      {{ secondsToDurationString(slot.durationInSeconds) }}
                    </span>
                  </th>
                  <td
                    role="cell"
                    class="slot-data tw-block lg-tw-table-cell tw-w-full tw-align-top tw-rounded"
                    :class="{
                      'tw-bg-gray-50': slot.type === 'show',
                      'tw-bg-amber-50': slot.type === 'intermission',
                    }"
                  >
                    <template v-if="slot.type === 'intermission'">
                      {{ t('calendar.intermission') }}
                    </template>
    
                    <div v-if="slot.type === 'show'" class="tw-flex tw-flex-col tw-gap-2">
                      <div>
                        <div class="tw-float-right">
                          <button
                            type="button"
                            class="btn btn-sm btn-default"
                            :title="t('calendar.editTimeslot')"
                            @click="editTimeslot(slot.timeslot)"
                          >
                            <icon-system-uicons-pen />
                          </button>
                        </div>
                        <SafeHTML
                          :html="slot.show?.name ?? ''"
                          sanitize-preset="inline-noninteractive"
                          as="p"
                          class="tw-font-bold tw-m-0"
                        />
                      </div>
    
                      <div
                        class="tw-grid tw-gap-1 tw-text-sm empty:tw-hidden"
                        style="grid-template-columns: max-content minmax(0, 1fr)"
                      >
                        <template
                          v-if="
                            (slot.timeslot.playlistId || slot.show?.defaultPlaylistId) && slot.playlist
                          "
                        >
                          <span>{{ t('emissionTable.playlist') }}:</span>
                          <span>{{ slot.playlist.description }}</span>
                        </template>
                        <template v-if="slot.playlistDurationInSeconds">
                          <span>{{ t('emissionTable.duration') }}:</span>
                          <span>{{ secondsToDurationString(slot.playlistDurationInSeconds) }}</span>
                        </template>
                      </div>
    
                      <div class="tw-flex tw-gap-2">
                        <span
                          v-if="now > slot.start && now < slot.end"
                          :class="pillClasses"
                          class="tw-bg-teal-200"
                        >
                          {{ t('calendar.playing') }}
                        </span>
    
                        <span
                          v-if="slot.timeslot.repetitionOfId"
                          :class="pillClasses"
                          class="tw-bg-amber-200"
                        >
                          {{ t('calendar.repetition') }}
                        </span>
                        <span
                          v-if="
                            slot.playlistDurationInSeconds !== null &&
                            slot.timeslotDurationInSeconds !== slot.playlistDurationInSeconds
                          "
                          :class="pillClasses"
                          class="tw-bg-amber-200"
                        >
                          {{ t('calendar.mismatchedLength') }}
                        </span>
                        <span
                          v-if="!slot.timeslot.playlistId && slot.show?.defaultPlaylistId"
                          :class="pillClasses"
                          class="tw-bg-rose-200"
                        >
                          {{ t('calendar.fallback') }}
                        </span>
                        <span
                          v-else-if="!slot.timeslot.playlistId && !slot.show?.defaultPlaylistId"
                          :class="pillClasses"
                          class="tw-bg-rose-200"
                        >
                          {{ t('calendar.empty') }}
                        </span>
                      </div>
                    </div>
                  </td>
                </tr>
              </template>
            </tbody>
          </table>
    
        </div>
      </div>
    </template>
    
    <script lang="ts" setup>
    
    import { endOfDay, formatISO, parseISO, startOfDay } from 'date-fns'
    
    import { computed } from 'vue'
    
    
    import SafeHTML from '@/components/generic/SafeHTML'
    import { useI18n } from '@/i18n'
    
    import { Playlist, Show, TimeSlot } from '@/types'
    import { calculatePlaylistDurationInSeconds, usePlaylistStore } from '@/stores/playlists'
    
    import { getISODateString } from '@/utilities'
    import {
      calculateDurationSeconds,
      mapToDomain,
      secondsToDurationString,
      useFormattedISODate,
    
    import { useShowStore, useTimeSlotStore } from '@/stores'
    import { usePaginatedList } from '@rokoli/bnb/drf'
    
    
    const pillClasses = 'tw-py-1 tw-px-2 tw-text-xs tw-rounded-full'
    
    type BaseSlot = {
      start: Date
      end: Date
      durationInSeconds: number
    }
    
    type IntermissionSlot = BaseSlot & {
      type: 'intermission'
    }
    
    type ShowSlot = BaseSlot & {
      type: 'show'
    
      timeslot: TimeSlot
      timeslotDurationInSeconds: number
      show: Show | undefined
      playlist: Playlist | undefined
      playlistDurationInSeconds: number | null
    }
    
    
    const props = defineProps<{
      selectedDay: Date
    }>()
    const emit = defineEmits<{
      changeDay: [offset: number]
      editTimeslot: [timeslot: TimeSlot]
    }>()
    
    const playlistStore = usePlaylistStore()
    
    const showStore = useShowStore()
    
    const timeslotStore = useTimeSlotStore()
    
    const now = useNow({ interval: 60_000 })
    const nowDateString = useFormattedISODate(now)
    const startOfSelectedDay = computed(() => startOfDay(props.selectedDay))
    const endOfSelectedDay = computed(() => endOfDay(props.selectedDay))
    
    const selectedDayISODate = computed(() => getISODateString(props.selectedDay))
    
    
    // TODO: this does not support more than 100 timeslots per day
    const { result: timeslotResult } = usePaginatedList(timeslotStore.listIsolated, 1, 100, {
      query: useQuery(() => ({
        endsAfter: formatISO(startOfSelectedDay.value),
        startsBefore: formatISO(endOfSelectedDay.value),
        order: 'start',
      })),
    })
    
    
    const slots = computed<Slot[]>(() => {
      const result: Slot[] = []
      let start = new Date(startOfSelectedDay.value)
      const endOfToday = new Date(endOfSelectedDay.value)
    
      const daySlots = Array.from(timeslotResult.value.items)
    
    
      for (const timeslot of daySlots) {
    
        const showSlot = createShowSlot(timeslot)
        if (showSlot.start > start) {
          // this slot starts some time after the previous slot ended
          // that means we got an intermission between the last timeslot and the current one
          result.push(createIntermissionSlot(start, showSlot.start))
        }
        start = showSlot.end
        result.push(showSlot)
      }
    
      if (start < endOfToday) {
        // the last timeslot of this day ended before the end of the day
        // that means we got an intermission between the end of the last show and the end of the selected day
        result.push(createIntermissionSlot(start, endOfToday))
    
    function createIntermissionSlot(start: Date, end: Date): IntermissionSlot {
      return {
        type: 'intermission',
        start,
        end,
        durationInSeconds: calculateDurationSeconds(start, end),
    
    
    function createShowSlot(timeslot: TimeSlot): ShowSlot {
    
      // enqueue a request for the show in case it’s not yet in the store
      void showStore.retrieve(timeslot.showId, { useCached: true })
      const show = showStore.itemMap.get(timeslot.showId)
    
      const playlistId = timeslot.playlistId ?? show?.defaultPlaylistId
    
      // enqueue a request for the show in case it’s not yet in the store
      if (playlistId) void playlistStore.retrieve(playlistId, { useCached: true })
    
      const playlist = playlistId ? playlistStore.itemMap.get(playlistId) : undefined
    
      const start = parseISO(timeslot.start)
      const end = parseISO(timeslot.end)
      const timeslotDurationInSeconds = calculateDurationSeconds(start, end)
      const playlistDurationInSeconds = playlist ? calculatePlaylistDurationInSeconds(playlist) : null
      return {
        type: 'show',
        start,
        end,
        durationInSeconds: calculateDurationSeconds(start, end),
        timeslot,
        timeslotDurationInSeconds,
        playlist,
        playlistDurationInSeconds,
        show,
      }
    }
    
    function formatTime(date: Date) {
      if (date < startOfSelectedDay.value || date > endOfSelectedDay.value) {
        return date.toLocaleString(locale.value, {
          dateStyle: 'short',
          timeStyle: 'short',
        })
      }
      return date.toLocaleString(locale.value, {
        timeStyle: 'short',
      })
    }
    
    function editTimeslot(timeslot: TimeSlot) {
      emit('editTimeslot', timeslot)
    }
    
    
    <style lang="postcss" scoped>
    .slot-table {
      width: min(800px, 100%);
    }
    
    .slot-table :is(th, td) {
      @apply tw-p-2;
    }
    
    .slot-table th {
      @apply tw-pr-4;
    }
    
    .slot.slot--today.slot--past {
      @apply tw-opacity-75;
    }
    
    .slot-time-indicator {
      position: absolute;
      top: 0;
      bottom: 0;
      right: 100%;
      width: 0.35rem;
      border: solid theme('colors.gray.300');
      border-width: 1px 1px 1px 0;
    
      & > span {
        position: absolute;
        right: 0;
        width: 0.5rem;
        height: 1px;
        background-color: var(--fc-now-indicator-color);
      }
    }
    </style>