diff --git a/src/components/CalendarDayView.vue b/src/components/CalendarDayView.vue index 89726311ee295d6e08487af652ac0c1203385657..75250df486eb2acd08e4c07ed7e844f4ae99ca7f 100644 --- a/src/components/CalendarDayView.vue +++ b/src/components/CalendarDayView.vue @@ -1,108 +1,224 @@ <template> - <div class="schedule-panel tw-w-full"> - <div class="tw-flex tw-items-center tw-justify-between tw-mb-4"> - <h3>{{ prettyDate(selectedDay) }}</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> - - <div - v-for="{ - timeslot, - timeslotDurationInSeconds, - playlist, - playlistDurationInSeconds, - show, - } in slotData" - :key="timeslot.id" - class="timeslot tw-w-full tw-py-2 tw-px-3 tw-rounded hover:tw-bg-gray-200 hover:tw-text-gray-900 tw-cursor-pointer tw-border tw-border-solid tw-border-gray-200 tw-mb-2" - :class="{ - 'tw-bg-gray-900 tw-text-white': - now >= parseISO(timeslot.start) && now <= parseISO(timeslot.end), - 'tw-bg-gray-200 tw-text-gray-600 tw-opacity-75': now >= parseISO(timeslot.end), - }" - @click="onTimeslotClick(timeslot)" - > - <div class="tw-flex tw-justify-between tw-items-center"> - <div> - <p class="tw-mb-0 tw-leading-tight tw-font-bold"> - <SafeHTML v-if="show" :html="show.name" sanitize-preset="inline-noninteractive" /> - </p> - <span class="tw-text-sm"> - {{ prettyTime(timeslot.start) }} - {{ prettyTime(timeslot.end) }} - <span v-if="timeslot.repetitionOfId" class="tw-text-gray-400"> - {{ t('calendar.repetition') }} - </span> - </span> - </div> + <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 v-if="(timeslot.playlistId || show?.defaultPlaylistId) && playlist"> - <p> - <strong>{{ t('emissionTable.playlist') }}:</strong> - {{ playlist.description }} - <span - v-if="!timeslot.playlistId && show?.defaultPlaylistId" - class="tw-text-xs tw-text-red-500" + <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)" > - {{ t('calendar.fallback') }} - </span> - </p> - - <p v-if="timeslot.playlistId" class="tw-text-sm"> - <strong>{{ t('emissionTable.duration') }}:</strong> - {{ secondsToDurationString(playlistDurationInSeconds ?? timeslotDurationInSeconds) }} - <span - v-if=" - playlistDurationInSeconds !== null && - timeslotDurationInSeconds !== playlistDurationInSeconds - " - class="is-mismatched" + <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)" > - {{ t('calendar.mismatchedLength') }} - </span> - </p> + <span class="fc-icon fc-icon-chevron-right"></span> + </button> + </div> </div> - - <p v-else> - {{ t('calendar.empty') }} - </p> </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 SafeHTML from '@/components/generic/SafeHTML' -import { usePretty } from '@/mixins/prettyDate' -import { useI18n } from '@/i18n' +import { endOfDay, parseISO, startOfDay } from 'date-fns' +import { sortBy } from 'lodash' +import { useNow } from '@vueuse/core' import { computed } from 'vue' -import { getISODateString, useSelectedShow } from '@/utilities' import { useStore } from 'vuex' + +import SafeHTML from '@/components/generic/SafeHTML' +import { useI18n } from '@/i18n' import { Playlist, Show, TimeSlot } from '@/types' -import { parseISO } from 'date-fns' import { calculatePlaylistDurationInSeconds, usePlaylistStore } from '@/stores/playlists' -import { calculateDurationSeconds, secondsToDurationString } from '@/util' -import { useNow } from '@vueuse/core' +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 SlotData = { +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 @@ -110,6 +226,8 @@ type SlotData = { playlistDurationInSeconds: number | null } +type Slot = IntermissionSlot | ShowSlot + const props = defineProps<{ selectedDay: Date }>() @@ -117,50 +235,142 @@ const emit = defineEmits<{ changeDay: [offset: number] editTimeslot: [timeslot: TimeSlot] }>() -const { t } = useI18n() -const { prettyDate, prettyTime } = usePretty() -const now = useNow({ interval: 60_000 }) +const { t, locale } = useI18n() 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 { itemMap: playlistMap, retrieve: retrievePlaylist } = usePlaylistStore() +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 slotData = computed<SlotData[]>(() => { - const result: SlotData[] = [] - const daySlots = timeslots.value.filter( - (timeslot) => timeslot.start.split('T')[0] === selectedDayISODate.value, +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 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 timeslotDurationInSeconds = calculateDurationSeconds( - parseISO(timeslot.start), - parseISO(timeslot.end), - ) - const playlistDurationInSeconds = playlist ? calculatePlaylistDurationInSeconds(playlist) : null - - result.push({ - timeslot, - timeslotDurationInSeconds, - playlist, - playlistDurationInSeconds, - show, - }) + 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 onTimeslotClick(timeslot: TimeSlot) { - if (selectedShow.value.id !== timeslot.showId) { - selectedShow.value = { id: timeslot.showId } as Show - } else { - emit('editTimeslot', timeslot) +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) +} </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> diff --git a/src/i18n/de.js b/src/i18n/de.js index c579df6484ecbd967ddc9a0ebc0a2eac9abb0766..30d6ad63f90e09a76eb248968e0939902fc15b6f 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -468,14 +468,18 @@ export default { calendar: { today: 'Heute', - empty: '(Keine Playlist!)', - fallback: 'Achtung! Fallback-Playlist!', - repetition: '(Wiederholung)', + empty: 'Keine Playlist', + fallback: 'Fallback-Playlist', + repetition: 'Wiederholung', view: { day: 'Tagesansicht', week: 'Wochenansicht', }, - mismatchedLength: '(Unpassende Länge)', + switchShow: 'Zur „%{show}“ Sendereihe wechseln', + editTimeslot: 'Sendung bearbeiten', + intermission: 'Programmunterbrechung', + mismatchedLength: 'Unpassende Länge', + playing: 'Spielt gerade', }, // Etc diff --git a/src/i18n/en.js b/src/i18n/en.js index 672a92daa91c944db2af424e465302a41d7592ab..e593d53f84f11634e88668b434fce58e604f6c55 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -459,14 +459,18 @@ export default { calendar: { today: 'Today', - empty: '(No playlist! Station fallback will be used)', - fallback: 'Warning! Fallback playlist!', - repetition: '(Repetition)', + empty: 'No playlist', + fallback: 'Fallback playlist', + repetition: 'Repetition', view: { day: 'Day view', week: 'Week view', }, - mismatchedLength: '(wrong duration)', + switchShow: 'Switch to “%{show}†show', + editTimeslot: 'Sendung bearbeiten', + intermission: 'Program intermission', + mismatchedLength: 'wrong duration', + playing: 'Currently playing', }, // Etc