-
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.
Konrad Mohrfeldt authoredThis 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>