Skip to content
Snippets Groups Projects
  • Konrad Mohrfeldt's avatar
    24cb7b94
    refactor: move all request options into separate object · 24cb7b94
    Konrad Mohrfeldt authored
    This should make the API functions more accessible for callers as there
    won’t be any loose undefined values that were sometimes needed to skip
    certain values in certain API endpoints.
    
    Instead, every positional argument is now required and all configurable
    options or extensions are part of the options object.
    24cb7b94
    History
    refactor: move all request options into separate object
    Konrad Mohrfeldt authored
    This should make the API functions more accessible for callers as there
    won’t be any loose undefined values that were sometimes needed to skip
    certain values in certain API endpoints.
    
    Instead, every positional argument is now required and all configurable
    options or extensions are part of the options object.
CalendarDayView.vue 12.29 KiB
<template>
  <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>
        </div>
      </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
}

type Slot = IntermissionSlot | ShowSlot

const props = defineProps<{
  selectedDay: Date
}>()
const emit = defineEmits<{
  changeDay: [offset: number]
  editTimeslot: [timeslot: TimeSlot]
}>()
const { t, locale } = useI18n()
const store = useStore()
const playlistStore = usePlaylistStore()
const selectedShow = useSelectedShow()
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))
  }

  return result
})

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) 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 switchShow(timeslot: TimeSlot) {
  selectedShow.value = { id: timeslot.showId } as Show
}

function editTimeslot(timeslot: TimeSlot) {
  emit('editTimeslot', timeslot)
}
</script>

<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>