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 @@ ...@@ -75,7 +75,7 @@
:end="args[1]" :end="args[1]"
@close="resolve(undefined)" @close="resolve(undefined)"
@conflict="conflictResponse = $event" @conflict="conflictResponse = $event"
@update="reloadTimeslots()" @update="reloadProgram()"
/> />
</CreateSchedule> </CreateSchedule>
...@@ -84,7 +84,7 @@ ...@@ -84,7 +84,7 @@
:timeslot="args[0]" :timeslot="args[0]"
@close="resolve(undefined)" @close="resolve(undefined)"
@conflict="conflictResponse = $event" @conflict="conflictResponse = $event"
@update="reloadTimeslots()" @update="reloadProgram()"
/> />
</EditSchedule> </EditSchedule>
</div> </div>
...@@ -92,16 +92,22 @@ ...@@ -92,16 +92,22 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useErrorList, usePaginatedList } from '@rokoli/bnb/drf' import { useErrorList } from '@rokoli/bnb/drf'
import { computedAsync, createTemplatePromise } from '@vueuse/core' import { computedAsync, createTemplatePromise } from '@vueuse/core'
import { addDays, parseISO } from 'date-fns' import { addDays, endOfDay, parseISO, startOfDay } from 'date-fns'
import { computed, ref, watchEffect } from 'vue' import { ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from '@/i18n' 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 { SteeringUser, useHasUserPermission } from '@/stores/auth'
import { ensureDate, getNextAvailableSlot, sanitizeHTML, useQuery } from '@/util' import { calculateDurationSeconds, getNextAvailableSlot, sanitizeHTML } from '@/util'
import { getISODateString } from '@/utilities' import { getISODateString } from '@/utilities'
import AScheduleCreateDialog from '@/components/schedule/AScheduleCreateDialog.vue' import AScheduleCreateDialog from '@/components/schedule/AScheduleCreateDialog.vue'
...@@ -115,6 +121,7 @@ import AScheduleCalendar from '@/components/schedule/AScheduleCalendar.vue' ...@@ -115,6 +121,7 @@ import AScheduleCalendar from '@/components/schedule/AScheduleCalendar.vue'
import AConflictResolver from '@/components/schedule/AConflictResolver.vue' import AConflictResolver from '@/components/schedule/AConflictResolver.vue'
import { ScheduleConflictResponse, TimeSlot } from '@/types' import { ScheduleConflictResponse, TimeSlot } from '@/types'
import { DateSelectArg, EventClickArg } from '@fullcalendar/core' import { DateSelectArg, EventClickArg } from '@fullcalendar/core'
import { useProgramSlots } from '@/stores/program'
const slotDurationMinutes = 15 const slotDurationMinutes = 15
...@@ -128,6 +135,7 @@ const authStore = useAuthStore() ...@@ -128,6 +135,7 @@ const authStore = useAuthStore()
const rruleStore = useRRuleStore() const rruleStore = useRRuleStore()
const showStore = useShowStore() const showStore = useShowStore()
const timeslotStore = useTimeSlotStore() const timeslotStore = useTimeSlotStore()
const playlistStore = usePlaylistStore()
const canAddSchedule = useHasUserPermission(['program.add_schedule']) const canAddSchedule = useHasUserPermission(['program.add_schedule'])
const CreateSchedule = createTemplatePromise<undefined, [Date, Date]>() const CreateSchedule = createTemplatePromise<undefined, [Date, Date]>()
...@@ -140,38 +148,38 @@ const currentEnd = ref<Date>(addDays(currentStart.value, 7)) ...@@ -140,38 +148,38 @@ const currentEnd = ref<Date>(addDays(currentStart.value, 7))
const conflictResponse = ref<ScheduleConflictResponse | undefined>() const conflictResponse = ref<ScheduleConflictResponse | undefined>()
const { const {
result: paginatedTimeslots, program,
reload: reloadTimeslots, reload: reloadProgram,
isLoading: isUpdatingData, isLoading: isUpdatingData,
error, } = useProgramSlots({
} = usePaginatedList(timeslotStore.listIsolated, 1, 10_000, { start: () => startOfDay(currentStart.value),
query: useQuery(() => ({ end: () => endOfDay(currentEnd.value),
endsAfter: getISODateString(currentStart.value),
startsBefore: getISODateString(currentEnd.value),
})),
}) })
const error = ref<Error | null>(null)
const errorList = useErrorList(error) 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 calendarEventsCache = new Map()
const calendarEvents = computedAsync(async () => { const calendarEvents = computedAsync(async () => {
const result = [] const result = []
// fetch all relevant shows upfront // fetch relevant objects upfront
await showStore.retrieveMultiple( await Promise.allSettled([
paginatedTimeslots.value.items.map((t) => t.showId), showStore.retrieveMultiple(
{ useCached: true }, 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) { for (const entry of program.value) {
const cacheKey = [timeslot.id, timeslot.playlistId, timeslot.start, timeslot.end].join(':') const cacheKey = [entry.id, entry.timeslotId, entry.playlistId].join(':')
const cachedSlot = calendarEventsCache.get(cacheKey) const cachedSlot = calendarEventsCache.get(cacheKey)
if (cachedSlot) { if (cachedSlot) {
...@@ -179,26 +187,32 @@ const calendarEvents = computedAsync(async () => { ...@@ -179,26 +187,32 @@ const calendarEvents = computedAsync(async () => {
continue continue
} }
const isEmpty = timeslot.playlistId === null const isEmpty = entry.playlistId === null
const emptyText = isEmpty ? ` ${t('calendar.empty')}` : '' 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 = const durationMinutes =
(ensureDate(timeslot.end).getTime() - ensureDate(timeslot.start).getTime()) / 1000 / 60 calculateDurationSeconds(ts?.start ?? entry.start, ts?.end ?? entry.end, false) / 60
const show = await showStore.retrieve(timeslot.showId, { useCached: true })
const isOwner = const isOwner =
show?.ownerIds?.includes?.(authStore?.steeringUser?.id as SteeringUser['id']) ?? false show?.ownerIds?.includes?.(authStore?.steeringUser?.id as SteeringUser['id']) ?? false
const className = ['calendar-event', isOwner ? 'is-mine' : 'is-not-mine'] const className = ['calendar-event', isOwner ? 'is-mine' : 'is-not-mine']
if (durationMinutes < 0) className.push('is-invalid') 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 title = sanitizeHTML(show?.name ?? '') + emptyText
const slot = { const slot = {
id: timeslot.id, id: entry.id,
start: timeslot.start, start: entry.start,
end: timeslot.end, end: entry.end,
className: className.join(' '), className: className.join(' '),
title, title,
display: entry.timeslotId === null ? 'block' : 'auto',
extendedProps: { extendedProps: {
title, title,
id: timeslot.id, id: entry.timeslotId,
durationMinutes: Math.abs(durationMinutes), durationMinutes: Math.abs(durationMinutes),
}, },
} }
...@@ -250,6 +264,6 @@ async function createEvent({ start, end }: DateSelectArg) { ...@@ -250,6 +264,6 @@ async function createEvent({ start, end }: DateSelectArg) {
async function onConflictResolve(reload: boolean) { async function onConflictResolve(reload: boolean) {
conflictResponse.value = undefined conflictResponse.value = undefined
if (reload) void reloadTimeslots() if (reload) void reloadProgram()
} }
</script> </script>
...@@ -90,6 +90,9 @@ const calendarConfig = computed<CalendarOptions>(() => ({ ...@@ -90,6 +90,9 @@ const calendarConfig = computed<CalendarOptions>(() => ({
selectAllow({ start }) { selectAllow({ start }) {
return start >= getClosestSlot(props.slotDurationMinutes) return start >= getClosestSlot(props.slotDurationMinutes)
}, },
selectOverlap: function (event) {
return event.display === 'block' || event.display === 'background'
},
navLinkDayClick(selectedDate) { navLinkDayClick(selectedDate) {
emit('selectDay', selectedDate) emit('selectDay', selectedDate)
}, },
......
...@@ -18,7 +18,8 @@ body { ...@@ -18,7 +18,8 @@ body {
.fc-timegrid-now-indicator-line, .fc-timegrid-now-indicator-line,
.fc-timegrid-now-indicator-arrow { .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; mix-blend-mode: multiply;
} }
} }
...@@ -120,6 +121,15 @@ thead .fc-day-selected:hover { ...@@ -120,6 +121,15 @@ thead .fc-day-selected:hover {
transparent 100% 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 { @layer components {
...@@ -273,7 +283,7 @@ thead .fc-day-selected:hover { ...@@ -273,7 +283,7 @@ thead .fc-day-selected:hover {
display: none; display: none;
} }
&.calendar-event { &.is-normal {
@apply tw-bg-gray-100 tw-border-gray-200 tw-text-gray-900 hocus:tw-bg-gray-200; @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 { ...@@ -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; @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 { &.is-invalid {
@apply tw-bg-amber-200 tw-border-amber-300 tw-text-amber-950 hocus:tw-bg-amber-300; @apply tw-bg-amber-200 tw-border-amber-300 tw-text-amber-950 hocus:tw-bg-amber-300;
& .fc-event-main { & .fc-event-main {
...@@ -290,6 +313,7 @@ thead .fc-day-selected:hover { ...@@ -290,6 +313,7 @@ thead .fc-day-selected:hover {
@apply tw-absolute tw-inset-0 tw-bg-stripes tw-block tw-bg-transparent; @apply tw-absolute tw-inset-0 tw-bg-stripes tw-block tw-bg-transparent;
content: ''; content: '';
background-size: 10px 10px; 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