Skip to content
Snippets Groups Projects
CalendarDayView.vue 12.3 KiB
Newer Older
  <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
                        v-if="slot.timeslot.showId === selectedShow.id"
                        type="button"
                        class="btn btn-sm btn-default"
                        :title="t('calendar.editTimeslot')"
                        @click="editTimeslot(slot.timeslot)"
                      >
                        <icon-system-uicons-pen />
                      </button>

                      <button
                        v-if="slot.timeslot.showId !== selectedShow.id"
                        class="btn btn-sm btn-default"
                        :title="
                          t('calendar.switchShow', { show: sanitizeHTML(slot.show?.name ?? '') })
                        "
                        @click="switchShow(slot.timeslot)"
                      >
                        <icon-system-uicons-reverse />
                      </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, parseISO, startOfDay } from 'date-fns'
import { sortBy } from 'lodash'
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
import { useStore } from 'vuex'

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,
  sanitizeHTML,
  secondsToDurationString,
  useFormattedISODate,
  useSelectedShow,
} from '@/util'

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 store = useStore()
const selectedShow = useSelectedShow()
const { itemMap: playlistMap, retrieve: retrievePlaylist } = usePlaylistStore()
const shows = computed<Show[]>(() => store.state.shows.shows)
const timeslots = computed<TimeSlot[]>(() => store.state.shows.timeslots)
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))
const slots = computed<Slot[]>(() => {
  const result: Slot[] = []
  let start = new Date(startOfSelectedDay.value)
  const endOfToday = new Date(endOfSelectedDay.value)
  const daySlots = sortBy(
    timeslots.value.filter((timeslot) =>
      // we want either the start date or the end date to be on the currently selected day
      // so that timeslots that wrap around days are included
      [
        extractDateFromDateTimeString(timeslot.start),
        extractDateFromDateTimeString(timeslot.end),
      ].includes(selectedDayISODate.value),
    ),
    (timeslot) => timeslot.start,
  )

  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 extractDateFromDateTimeString(date: string) {
  return date.split('T')[0]
}

function createIntermissionSlot(start: Date, end: Date): IntermissionSlot {
  return {
    type: 'intermission',
    start,
    end,
    durationInSeconds: calculateDurationSeconds(start, end),

function createShowSlot(timeslot: TimeSlot): ShowSlot {
  const show = shows.value.find(({ id }) => timeslot.showId === id)
  const playlistId = timeslot.playlistId ?? show?.defaultPlaylistId
  // enqueue a request for the playlist in case it’s not yet in the store
  if (playlistId) retrievePlaylist(playlistId, undefined, { useCached: true })
  const playlist = playlistId ? playlistMap.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 switchShow(timeslot: TimeSlot) {
  selectedShow.value = { id: timeslot.showId } as Show
}

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>