diff --git a/src/Pages/Calendar.vue b/src/Pages/Calendar.vue index b9651d432d5fbfd52fc6abc80d44b2983de12c36..1e8852bb4f275ece9b178fb196c945d54c3ac973 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 9825b65819b5cec65316db4c0f300ca60834785e..6e60e24e2d90ed5086bcd81a025ea075ec415861 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 b96b92ede8ada33b1259b448e8b0031031be8a0d..332bbec4ba53b362b9ac14826147187a66afe2f4 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; } } }