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

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
parent b2f9080c
No related branches found
No related tags found
No related merge requests found
......@@ -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>
......@@ -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)
},
......
......@@ -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;
}
}
}
......
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