From a170151e037fc8ece206c9813ee74230b1830ab5 Mon Sep 17 00:00:00 2001 From: Konrad Mohrfeldt <km@roko.li> Date: Tue, 23 Jul 2024 00:54:47 +0200 Subject: [PATCH] refactor: rework calendar day view Unfortunately, this commit combines a few changes: * the calendar day view now uses the /program endpoint like the week view and therefore no longer needs to generate slots that fill empty spots in the program * the visual presentation has been reworked so that it is more in line with the changes to the week view * the look has been changed to a continuous timelime giving users clear feedback on what has been broadcasted, what is being broadcasted and what will be broadcasted refs #128 --- src/components/CalendarDayView.vue | 372 +++--------------- src/components/calendar/ACalendarDayEntry.vue | 150 +++++++ src/components/generic/ATimeline.vue | 5 + src/components/generic/ATimelineContent.vue | 5 + src/components/generic/ATimelineHeader.vue | 5 + src/components/generic/ATimelineIcon.vue | 26 ++ src/components/generic/ATimelineItem.vue | 78 ++++ src/i18n/de.js | 6 + src/i18n/en.js | 6 + src/util/index.ts | 9 +- 10 files changed, 335 insertions(+), 327 deletions(-) create mode 100644 src/components/calendar/ACalendarDayEntry.vue create mode 100644 src/components/generic/ATimeline.vue create mode 100644 src/components/generic/ATimelineContent.vue create mode 100644 src/components/generic/ATimelineHeader.vue create mode 100644 src/components/generic/ATimelineIcon.vue create mode 100644 src/components/generic/ATimelineItem.vue diff --git a/src/components/CalendarDayView.vue b/src/components/CalendarDayView.vue index 8d3c6084..462f6e31 100644 --- a/src/components/CalendarDayView.vue +++ b/src/components/CalendarDayView.vue @@ -1,219 +1,59 @@ <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" + <ASection + :title="selectedDay.toLocaleString(locale, { dateStyle: 'long' })" + class="tw-w-fit tw-flex-none" + > + <template #header> + <div class="tw-flex tw-items-center tw-justify-between tw-place-self-center tw-m-0"> + <div class="fc fc-direction-ltr"> + <div class="fc-button-group"> + <button + type="button" + class="fc-button fc-next-button fc-button-primary" + @click="emit('changeDay', -1)" > - <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="fc-icon fc-icon-chevron-left"></span> + </button> - <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 dark:tw-bg-neutral-900': slot.type === 'show', - 'tw-bg-amber-50 dark:tw-bg-amber-950': slot.type === 'intermission', - }" + <button + type="button" + class="fc-button fc-next-button fc-button-primary" + @click="emit('changeDay', 1)" > - <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 dark:tw-bg-teal-700" - > - {{ t('calendar.playing') }} - </span> - - <span - v-if="slot.timeslot.repetitionOfId" - :class="pillClasses" - class="tw-bg-amber-200 dark:tw-bg-amber-700" - > - {{ t('calendar.repetition') }} - </span> - <span - v-if=" - slot.playlistDurationInSeconds !== null && - slot.timeslotDurationInSeconds !== slot.playlistDurationInSeconds - " - :class="pillClasses" - class="tw-bg-amber-200 dark:tw-bg-amber-700" - > - {{ t('calendar.mismatchedLength') }} - </span> - <span - v-if="!slot.timeslot.playlistId && slot.show?.defaultPlaylistId" - :class="pillClasses" - class="tw-bg-rose-200 dark:tw-bg-rose-700" - > - {{ t('calendar.fallback') }} - </span> - <span - v-else-if="!slot.timeslot.playlistId && !slot.show?.defaultPlaylistId" - :class="pillClasses" - class="tw-bg-rose-200 dark:tw-bg-rose-700" - > - {{ t('calendar.empty') }} - </span> - </div> - </div> - </td> - </tr> - </template> - </tbody> - </table> - </div> + <span class="fc-icon fc-icon-chevron-right"></span> + </button> + </div> + </div> + </div> + </template> + + <ATimeline class="tw-max-w-[600px]"> + <ACalendarDayEntry + v-for="entry in programSlots" + :key="entry.id" + :entry="entry" + :now="now" + :start-of-selected-day="startOfSelectedDay" + :end-of-selected-day="endOfSelectedDay" + @edit="editTimeslot($event)" + /> + </ATimeline> + </ASection> </div> </template> <script lang="ts" setup> -import { endOfDay, formatISO, parseISO, startOfDay } from 'date-fns' +import { endOfDay, 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 +import { TimeSlot } from '@/types' +import { useProgramSlots } from '@/stores/program' +import ATimeline from '@/components/generic/ATimeline.vue' +import ASection from '@/components/generic/ASection.vue' +import ACalendarDayEntry from '@/components/calendar/ACalendarDayEntry.vue' const props = defineProps<{ selectedDay: Date @@ -222,134 +62,16 @@ const emit = defineEmits<{ changeDay: [offset: number] editTimeslot: [timeslot: TimeSlot] }>() -const { t, locale } = useI18n() -const playlistStore = usePlaylistStore() -const showStore = useShowStore() -const timeslotStore = useTimeSlotStore() +const { locale } = useI18n() 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 +const { program: programSlots } = useProgramSlots({ + start: startOfSelectedDay, + end: endOfSelectedDay, }) -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> diff --git a/src/components/calendar/ACalendarDayEntry.vue b/src/components/calendar/ACalendarDayEntry.vue new file mode 100644 index 00000000..cf94ebb3 --- /dev/null +++ b/src/components/calendar/ACalendarDayEntry.vue @@ -0,0 +1,150 @@ +<template> + <ATimelineItem + :key="entry.id" + :image-id="show?.logoId" + :progression="mapToDomain(now.getTime(), [start.getTime(), end.getTime()], [0, 100])" + class="tw-w-[450px]" + > + <ATimelineIcon + :title="t(entry.timeslotId ? 'programEntry.scheduled' : 'programEntry.fallback')" + > + <icon-ph-calendar-check-light v-if="entry.timeslotId" class="tw-size-6" /> + <icon-ph-warning-light v-else class="tw-size-6 tw-relative -tw-top-px" /> + </ATimelineIcon> + + <ATimelineHeader class="tw-mt-[-2px]"> + <p class="tw-tabular-nums tw-flex tw-justify-between"> + <span class="tw-font-medium">{{ formatTime(start) }} - {{ formatTime(end) }}</span> + <span class="tw-text-gray-500 dark:tw-text-neutral-500"> + {{ secondsToDurationString(durationInSeconds) }} + </span> + </p> + </ATimelineHeader> + + <ATimelineContent + class="tw-shadow dark:tw-bg-neutral-800 tw-p-3 tw-rounded tw-mb-9" + :class="{ + 'tw-bg-white': entry.timeslotId, + 'tw-bg-stripes tw-bg-stripes-fallback tw-bg-yellow-50 tw-border-yellow-50 tw-text-yellow-900 dark:tw-bg-yellow-950 dark:tw-border-yellow-950 dark:tw-text-yellow-100': + !entry.timeslotId, + }" + > + <div v-if="timeslot" class="tw-float-right"> + <button + v-if="timeslot" + type="button" + class="btn btn-sm btn-default tw-rounded-full tw-size-8 tw-p-0 tw-justify-center" + :title="t('calendar.editTimeslot')" + @click="emit('edit', timeslot as TimeSlot)" + > + <icon-system-uicons-pen /> + </button> + </div> + + <div class="tw-flex tw-gap-x-2 tw-items-center"> + <Image :image="show?.logoId" class="tw-size-6 tw-rounded tw-bg-gray-300" square /> + <SafeHTML :html="show?.name ?? ''" sanitize-preset="inline-noninteractive" as="p" /> + </div> + + <SafeHTML + v-if="episode?.summary?.trim()" + :html="episode.summary" + sanitize-preset="safe-html" + as="div" + class="tw-text-gray-500 tw-mt-2 tw-text-pretty" + /> + + <div class="tw-flex tw-gap-2 tw-mt-2 empty:tw-hidden"> + <APill + v-if="formatISO(now) > entry.start && formatISO(now) < entry.end" + class="tw-text-teal-700 tw-text-sm" + > + {{ t('calendar.playing') }} + </APill> + + <template v-if="entry.timeslotId"> + <APill v-if="timeslot?.repetitionOfId" class="tw-text-teal-700 tw-text-sm"> + {{ t('calendar.repetition') }} + </APill> + <APill + v-if=" + playlistDurationInSeconds !== null && + timeslotDurationInSeconds !== playlistDurationInSeconds + " + class="tw-text-amber-700 tw-text-sm" + > + {{ t('calendar.mismatchedLength') }} + </APill> + <APill v-if="timeslot && !timeslot.playlistId" class="tw-text-amber-700 tw-text-sm"> + {{ t('calendar.fallback') }} + </APill> + </template> + + <APill v-else-if="!entry.playlistId" class="tw-text-rose-700 tw-text-sm"> + {{ t('calendar.empty') }} + </APill> + </div> + </ATimelineContent> + </ATimelineItem> +</template> + +<script setup lang="ts"> +import { calculateDurationSeconds, ensureDate, mapToDomain, secondsToDurationString } from '@/util' +import ATimelineItem from '@/components/generic/ATimelineItem.vue' +import Image from '@/components/generic/Image.vue' +import APill from '@/components/generic/APill.vue' +import ATimelineContent from '@/components/generic/ATimelineContent.vue' +import SafeHTML from '@/components/generic/SafeHTML' +import ATimelineHeader from '@/components/generic/ATimelineHeader.vue' +import ATimelineIcon from '@/components/generic/ATimelineIcon.vue' +import { ProgramEntry, TimeSlot } from '@/types' +import { calculatePlaylistDurationInSeconds, usePlaylistStore } from '@/stores/playlists' +import { useObjectFromStore } from '@rokoli/bnb/drf' +import { useNoteStore, useShowStore, useTimeSlotStore } from '@/stores' +import { computed } from 'vue' +import { useI18n } from '@/i18n' +import { formatISO } from 'date-fns' + +const props = defineProps<{ + entry: ProgramEntry + now: Date + startOfSelectedDay: Date + endOfSelectedDay: Date +}>() +const emit = defineEmits<{ edit: [TimeSlot] }>() + +const { locale, t } = useI18n() +const showStore = useShowStore() +const timeslotStore = useTimeSlotStore() +const playlistStore = usePlaylistStore() +const noteStore = useNoteStore() + +const start = computed(() => ensureDate(props.entry.start)) +const end = computed(() => ensureDate(props.entry.end)) +const durationInSeconds = computed(() => calculateDurationSeconds(start.value, end.value)) + +const { obj: show } = useObjectFromStore(() => props.entry.showId, showStore) + +const { obj: playlist } = useObjectFromStore(() => props.entry.playlistId, playlistStore) +const playlistDurationInSeconds = computed(() => + playlist.value ? calculatePlaylistDurationInSeconds(playlist.value) : null, +) + +const { obj: timeslot } = useObjectFromStore(() => props.entry.timeslotId, timeslotStore) +const timeslotDurationInSeconds = computed(() => + timeslot.value ? calculateDurationSeconds(timeslot.value.start, timeslot.value.end) : null, +) + +const { obj: episode } = useObjectFromStore(() => timeslot.value?.noteId ?? null, noteStore) + +function formatTime(date: Date) { + return date < props.startOfSelectedDay || date > props.endOfSelectedDay + ? date.toLocaleString(locale.value, { + dateStyle: 'short', + timeStyle: 'short', + }) + : date.toLocaleString(locale.value, { + timeStyle: 'short', + }) +} +</script> diff --git a/src/components/generic/ATimeline.vue b/src/components/generic/ATimeline.vue new file mode 100644 index 00000000..7d3a886b --- /dev/null +++ b/src/components/generic/ATimeline.vue @@ -0,0 +1,5 @@ +<template> + <ol> + <slot /> + </ol> +</template> diff --git a/src/components/generic/ATimelineContent.vue b/src/components/generic/ATimelineContent.vue new file mode 100644 index 00000000..4bcb64c6 --- /dev/null +++ b/src/components/generic/ATimelineContent.vue @@ -0,0 +1,5 @@ +<template> + <div style="grid-area: content"> + <slot /> + </div> +</template> diff --git a/src/components/generic/ATimelineHeader.vue b/src/components/generic/ATimelineHeader.vue new file mode 100644 index 00000000..af0fdb85 --- /dev/null +++ b/src/components/generic/ATimelineHeader.vue @@ -0,0 +1,5 @@ +<template> + <header style="grid-area: header" class="tw-mb-1"> + <slot /> + </header> +</template> diff --git a/src/components/generic/ATimelineIcon.vue b/src/components/generic/ATimelineIcon.vue new file mode 100644 index 00000000..93a47925 --- /dev/null +++ b/src/components/generic/ATimelineIcon.vue @@ -0,0 +1,26 @@ +<template> + <div style="grid-area: icon"> + <div + class="tw-overflow-hidden tw-rounded-full tw-border-[2px] tw-border-[--_timeline-track-default]" + v-bind="attrs" + > + <div + class="tw-p-2 tw-bg-white tw-relative tw-z-10 dark:tw-bg-neutral-800 tw-user-select-none" + > + <slot /> + </div> + </div> + </div> + <div style="grid-area: icon" class="a-timeline-progression"> + <div + class="tw-overflow-hidden tw-rounded-full tw-border-[2px] tw-border-[--_timeline-track-active] tw-aspect-square" + v-bind="attrs" + /> + </div> +</template> +<script setup lang="ts"> +import { useAttrs } from 'vue' + +defineOptions({ inheritAttrs: false }) +const attrs = useAttrs() +</script> diff --git a/src/components/generic/ATimelineItem.vue b/src/components/generic/ATimelineItem.vue new file mode 100644 index 00000000..c9d36885 --- /dev/null +++ b/src/components/generic/ATimelineItem.vue @@ -0,0 +1,78 @@ +<template> + <li + class="a-timeline-item tw-gap-x-3 tw-min-h-24" + :style="{ '--_timeline-track-progression': `${clampedProgression}%` }" + > + <div + class="a-timeline-item-track tw-h-full tw-w-[2px] tw-m-auto tw-bg-[--_timeline-track-default]" + style="grid-area: icon" + /> + <div + class="a-timeline-item-track a-timeline-progression tw-h-full tw-w-[2px] tw-m-auto tw-bg-[--_timeline-track-active]" + style="grid-area: icon" + /> + <slot /> + </li> +</template> + +<script lang="ts" setup> +import { computed } from 'vue' + +const props = defineProps<{ + progression?: number | null | undefined +}>() +const clampedProgression = computed(() => + Math.max(0, Math.min(100, Math.round(props.progression ?? 0))), +) +</script> + +<style lang="postcss"> +.a-timeline-item { + --_timeline-track-active: theme('colors.aura.primary'); + --_timeline-track-default: theme('colors.gray.200'); + --_timeline-track-progression: 0%; + + .tw-dark & { + --_timeline-track-default: theme('colors.neutral.700'); + } +} + +.a-timeline-progression { + mask-image: linear-gradient( + to bottom, + black 0%, + black var(--_timeline-track-progression), + transparent var(--_timeline-track-progression), + transparent 100% + ); +} +</style> + +<style lang="postcss" scoped> +.a-timeline-item { + display: grid; + grid-template-columns: [icon] min-content [content] minmax(0, 1fr); + grid-template-rows: min-content minmax(0, 1fr); + grid-template-areas: + 'icon header' + 'icon content'; +} + +.a-timeline-item-track { + position: relative; +} + +.a-timeline-item:last-child .a-timeline-item-track::before { + --_track-end-size: 6px; + + content: ''; + display: flex; + width: var(--_track-end-size); + height: var(--_track-end-size); + position: absolute; + left: calc(var(--_track-end-size) / -2 + 1px); + background-color: inherit; + border-radius: 50%; + bottom: 0; +} +</style> diff --git a/src/i18n/de.js b/src/i18n/de.js index 54903810..7ca1c829 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -10,6 +10,12 @@ export default { 'AURA ist Radioautomationssuite die speziell auf die Bedürfnisse von Freien Radios zugeschnitten ist. AURA wurde in der Gemeinschaft mehrerer österreichischer Community-Radios entwickelt und ist quelloffen.', }, + programEntry: { + scheduled: 'Dieser Programplatz wurde regulär geplant.', + fallback: + 'Dieser Programmplatz war frei und wurde automatisch durch das Rückfallprogramm des Radios ersetzt.', + }, + showManager: { title: 'Sendereihen', generalSettings: 'Allgemeine Einstellungen der Sendereihe', diff --git a/src/i18n/en.js b/src/i18n/en.js index 7b5c4449..b79b7ffc 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -10,6 +10,12 @@ export default { 'AURA is radio automation software for the special needs of community radios. AURA has been developed in several Austrian community radios and it is open source.', }, + programEntry: { + scheduled: 'This program slot was scheduled.', + fallback: + 'This program slot was empty and has automatically been filled with the station’s fallback program.', + }, + showManager: { title: 'Radio shows', generalSettings: 'General settings for show', diff --git a/src/util/index.ts b/src/util/index.ts index 6af488b0..90d5c7e3 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -144,8 +144,13 @@ export function stripSecondsFromTimeString(time: string) { return /\d+:\d+:\d+$/.test(time) ? time.replace(/:\d+$/, '') : time } -export function calculateDurationSeconds(start: Date | string, end: Date | string): number { - return Math.abs(ensureDate(end).getTime() - ensureDate(start).getTime()) / 1000 +export function calculateDurationSeconds( + start: Date | string, + end: Date | string, + absolute = true, +): number { + const value = (ensureDate(end).getTime() - ensureDate(start).getTime()) / 1000 + return absolute ? Math.abs(value) : value } export function sanitizeHTML( -- GitLab