Skip to content
Snippets Groups Projects
Commit 2a68a7ac authored by Konrad Mohrfeldt's avatar Konrad Mohrfeldt :koala:
Browse files

feat: show intermissions and current time indicator in calendar day view

refs #155 #156
parent 552edd3f
No related branches found
No related tags found
No related merge requests found
Pipeline #3483 failed
<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>
......@@ -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
......
......@@ -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
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment