From f853d0f35a062fe8fe3402ce41c9febfcd443e71 Mon Sep 17 00:00:00 2001
From: Konrad Mohrfeldt <km@roko.li>
Date: Tue, 23 Jul 2024 00:49:18 +0200
Subject: [PATCH] feat: rework calendar week view to use /program endpoint as
 data source

This allows us to visualize a continuous program in the week view
without the need to manually generate slots to fill empty spots in the
program.

refs #128
---
 src/Pages/Calendar.vue                        | 92 +++++++++++--------
 src/components/schedule/AScheduleCalendar.vue |  3 +
 src/tailwind.css                              | 28 +++++-
 3 files changed, 82 insertions(+), 41 deletions(-)

diff --git a/src/Pages/Calendar.vue b/src/Pages/Calendar.vue
index b9651d43..1e8852bb 100644
--- a/src/Pages/Calendar.vue
+++ b/src/Pages/Calendar.vue
@@ -75,7 +75,7 @@
           :end="args[1]"
           @close="resolve(undefined)"
           @conflict="conflictResponse = $event"
-          @update="reloadTimeslots()"
+          @update="reloadProgram()"
         />
       </CreateSchedule>
 
@@ -84,7 +84,7 @@
           :timeslot="args[0]"
           @close="resolve(undefined)"
           @conflict="conflictResponse = $event"
-          @update="reloadTimeslots()"
+          @update="reloadProgram()"
         />
       </EditSchedule>
     </div>
@@ -92,16 +92,22 @@
 </template>
 
 <script lang="ts" setup>
-import { useErrorList, usePaginatedList } from '@rokoli/bnb/drf'
+import { useErrorList } from '@rokoli/bnb/drf'
 import { computedAsync, createTemplatePromise } from '@vueuse/core'
-import { addDays, parseISO } from 'date-fns'
-import { computed, ref, watchEffect } from 'vue'
+import { addDays, endOfDay, parseISO, startOfDay } from 'date-fns'
+import { ref, watchEffect } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 
 import { useI18n } from '@/i18n'
-import { useAuthStore, useRRuleStore, useShowStore, useTimeSlotStore } from '@/stores'
+import {
+  useAuthStore,
+  usePlaylistStore,
+  useRRuleStore,
+  useShowStore,
+  useTimeSlotStore,
+} from '@/stores'
 import { SteeringUser, useHasUserPermission } from '@/stores/auth'
-import { ensureDate, getNextAvailableSlot, sanitizeHTML, useQuery } from '@/util'
+import { calculateDurationSeconds, getNextAvailableSlot, sanitizeHTML } from '@/util'
 import { getISODateString } from '@/utilities'
 
 import AScheduleCreateDialog from '@/components/schedule/AScheduleCreateDialog.vue'
@@ -115,6 +121,7 @@ import AScheduleCalendar from '@/components/schedule/AScheduleCalendar.vue'
 import AConflictResolver from '@/components/schedule/AConflictResolver.vue'
 import { ScheduleConflictResponse, TimeSlot } from '@/types'
 import { DateSelectArg, EventClickArg } from '@fullcalendar/core'
+import { useProgramSlots } from '@/stores/program'
 
 const slotDurationMinutes = 15
 
@@ -128,6 +135,7 @@ const authStore = useAuthStore()
 const rruleStore = useRRuleStore()
 const showStore = useShowStore()
 const timeslotStore = useTimeSlotStore()
+const playlistStore = usePlaylistStore()
 const canAddSchedule = useHasUserPermission(['program.add_schedule'])
 
 const CreateSchedule = createTemplatePromise<undefined, [Date, Date]>()
@@ -140,38 +148,38 @@ const currentEnd = ref<Date>(addDays(currentStart.value, 7))
 const conflictResponse = ref<ScheduleConflictResponse | undefined>()
 
 const {
-  result: paginatedTimeslots,
-  reload: reloadTimeslots,
+  program,
+  reload: reloadProgram,
   isLoading: isUpdatingData,
-  error,
-} = usePaginatedList(timeslotStore.listIsolated, 1, 10_000, {
-  query: useQuery(() => ({
-    endsAfter: getISODateString(currentStart.value),
-    startsBefore: getISODateString(currentEnd.value),
-  })),
+} = useProgramSlots({
+  start: () => startOfDay(currentStart.value),
+  end: () => endOfDay(currentEnd.value),
 })
+const error = ref<Error | null>(null)
 const errorList = useErrorList(error)
 
-const showIds = computed(() => paginatedTimeslots.value.items.map((t) => t.showId))
-// this triggers a cache hit for other code
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-const shows = computedAsync(
-  () => showStore.retrieveMultiple(showIds.value, { useCached: true }),
-  [],
-)
-
 const calendarEventsCache = new Map()
 const calendarEvents = computedAsync(async () => {
   const result = []
 
-  // fetch all relevant shows upfront
-  await showStore.retrieveMultiple(
-    paginatedTimeslots.value.items.map((t) => t.showId),
-    { useCached: true },
-  )
+  // fetch relevant objects upfront
+  await Promise.allSettled([
+    showStore.retrieveMultiple(
+      program.value.map((e) => e.showId),
+      { useCached: true },
+    ),
+    timeslotStore.retrieveMultiple(
+      program.value.map((e) => e.timeslotId).filter((id) => id !== null) as number[],
+      { useCached: true },
+    ),
+    playlistStore.retrieveMultiple(
+      program.value.map((e) => e.playlistId).filter((id) => id !== null) as number[],
+      { useCached: true },
+    ),
+  ])
 
-  for (const timeslot of paginatedTimeslots.value.items) {
-    const cacheKey = [timeslot.id, timeslot.playlistId, timeslot.start, timeslot.end].join(':')
+  for (const entry of program.value) {
+    const cacheKey = [entry.id, entry.timeslotId, entry.playlistId].join(':')
     const cachedSlot = calendarEventsCache.get(cacheKey)
 
     if (cachedSlot) {
@@ -179,26 +187,32 @@ const calendarEvents = computedAsync(async () => {
       continue
     }
 
-    const isEmpty = timeslot.playlistId === null
-    const emptyText = isEmpty ? ` ${t('calendar.empty')}` : ''
+    const isEmpty = entry.playlistId === null
+    const emptyText = isEmpty ? `: ${t('calendar.empty')} âš ` : ''
+    const ts = entry.timeslotId
+      ? await timeslotStore.retrieve(entry.timeslotId, { useCached: true })
+      : null
+    const show = await showStore.retrieve(entry.showId, { useCached: true })
     const durationMinutes =
-      (ensureDate(timeslot.end).getTime() - ensureDate(timeslot.start).getTime()) / 1000 / 60
-    const show = await showStore.retrieve(timeslot.showId, { useCached: true })
+      calculateDurationSeconds(ts?.start ?? entry.start, ts?.end ?? entry.end, false) / 60
     const isOwner =
       show?.ownerIds?.includes?.(authStore?.steeringUser?.id as SteeringUser['id']) ?? false
     const className = ['calendar-event', isOwner ? 'is-mine' : 'is-not-mine']
     if (durationMinutes < 0) className.push('is-invalid')
+    if (entry.timeslotId === null) className.push('is-fallback')
+    else className.push('is-normal')
 
     const title = sanitizeHTML(show?.name ?? '') + emptyText
     const slot = {
-      id: timeslot.id,
-      start: timeslot.start,
-      end: timeslot.end,
+      id: entry.id,
+      start: entry.start,
+      end: entry.end,
       className: className.join(' '),
       title,
+      display: entry.timeslotId === null ? 'block' : 'auto',
       extendedProps: {
         title,
-        id: timeslot.id,
+        id: entry.timeslotId,
         durationMinutes: Math.abs(durationMinutes),
       },
     }
@@ -250,6 +264,6 @@ async function createEvent({ start, end }: DateSelectArg) {
 
 async function onConflictResolve(reload: boolean) {
   conflictResponse.value = undefined
-  if (reload) void reloadTimeslots()
+  if (reload) void reloadProgram()
 }
 </script>
diff --git a/src/components/schedule/AScheduleCalendar.vue b/src/components/schedule/AScheduleCalendar.vue
index 9825b658..6e60e24e 100644
--- a/src/components/schedule/AScheduleCalendar.vue
+++ b/src/components/schedule/AScheduleCalendar.vue
@@ -90,6 +90,9 @@ const calendarConfig = computed<CalendarOptions>(() => ({
   selectAllow({ start }) {
     return start >= getClosestSlot(props.slotDurationMinutes)
   },
+  selectOverlap: function (event) {
+    return event.display === 'block' || event.display === 'background'
+  },
   navLinkDayClick(selectedDate) {
     emit('selectDay', selectedDate)
   },
diff --git a/src/tailwind.css b/src/tailwind.css
index b96b92ed..332bbec4 100644
--- a/src/tailwind.css
+++ b/src/tailwind.css
@@ -18,7 +18,8 @@ body {
 
     .fc-timegrid-now-indicator-line,
     .fc-timegrid-now-indicator-arrow {
-      --fc-now-indicator-color: hsl(335deg 75% 40% / 80%);
+      --fc-now-indicator-color: theme('colors.aura.primary');
+      opacity: 0.8;
       mix-blend-mode: multiply;
     }
   }
@@ -120,6 +121,15 @@ thead .fc-day-selected:hover {
       transparent 100%
     );
   }
+
+  .tw-bg-stripes-fallback {
+    --tw-stripes-color: white;
+    background-size: 100px 100px;
+
+    .tw-dark & {
+      --tw-stripes-color: theme('colors.neutral.800');
+    }
+  }
 }
 
 @layer components {
@@ -273,7 +283,7 @@ thead .fc-day-selected:hover {
       display: none;
     }
 
-    &.calendar-event {
+    &.is-normal {
       @apply tw-bg-gray-100 tw-border-gray-200 tw-text-gray-900 hocus:tw-bg-gray-200;
     }
 
@@ -281,6 +291,19 @@ thead .fc-day-selected:hover {
       @apply tw-bg-gray-700 tw-border-gray-800 tw-text-white hocus:tw-bg-gray-800;
     }
 
+    &.is-fallback {
+      @apply tw-bg-yellow-50 tw-border-yellow-50 tw-text-yellow-900 dark:tw-bg-yellow-950 dark:tw-border-yellow-950 tw-text-yellow-100 tw-pointer-events-none;
+      & .fc-event-main {
+        @apply tw-relative tw-h-full;
+
+        &::after {
+          @apply tw-absolute tw-inset-0 tw-bg-stripes tw-bg-stripes-fallback tw-block tw-bg-transparent;
+          content: '';
+          z-index: -1;
+        }
+      }
+    }
+
     &.is-invalid {
       @apply tw-bg-amber-200 tw-border-amber-300 tw-text-amber-950 hocus:tw-bg-amber-300;
       & .fc-event-main {
@@ -290,6 +313,7 @@ thead .fc-day-selected:hover {
           @apply tw-absolute tw-inset-0 tw-bg-stripes tw-block tw-bg-transparent;
           content: '';
           background-size: 10px 10px;
+          z-index: -1;
         }
       }
     }
-- 
GitLab