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