<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 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 { useNow } from '@vueuse/core' 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, useQuery, } from '@/util' 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 } type Slot = IntermissionSlot | ShowSlot const props = defineProps<{ selectedDay: Date }>() const emit = defineEmits<{ changeDay: [offset: number] editTimeslot: [timeslot: TimeSlot] }>() const { t, locale } = useI18n() 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)) } return result }) 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) } </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>