<template> <c-r-u-d-iterator i18n-key="alsijil.coursebook" :gql-query="gqlQuery" :gql-additional-query-args="gqlQueryArgs" :enable-create="false" :enable-edit="false" :elevated="false" @lastQuery="lastQuery = $event" ref="iterator" disable-pagination hide-default-footer use-deep-search > <template #additionalActions="{ attrs, on }"> <coursebook-filters v-model="filters" /> </template> <template #default="{ items }"> <v-list-item v-for="{ date, docs, first, last } in groupDocsByDay(items)" v-intersect="onIntersect" :data-date="date.toISODate()" :data-first="first" :data-last="last" two-line :key="'day-' + date" :id="'documentation_' + date.toISODate()" > <v-list-item-content> <v-subheader class="text-h6">{{ $d(day[0], "dateWithWeekday") }}</v-subheader> <v-list max-width="100%" class="pt-0 mt-n1"> <v-list-item v-for="doc in docs" :key="'documentation-' + (doc.oldId || doc.id)" > <documentation-modal :documentation="doc" :affected-query="lastQuery" /> </v-list-item> </v-list> </v-list-item-content> </v-list-item> <date-select-footer :value="$route.hash.substring(1)" /> </template> <template #loading> <CoursebookLoader /> </template> <template #no-data> <CoursebookEmptyMessage icon="mdi-book-off-outline"> {{ $t("alsijil.coursebook.no_data") }} </CoursebookEmptyMessage> </template> <template #no-results> <CoursebookEmptyMessage icon="mdi-book-alert-outline"> {{ $t("alsijil.coursebook.no_results", { search: $refs.iterator.search }) }} </CoursebookEmptyMessage> </template> </c-r-u-d-iterator> </template> <script> import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; import DateSelectFooter from "aleksis.core/components/generic/DateSelectFooter.vue"; import DocumentationModal from "./documentation/DocumentationModal.vue"; import { DateTime, Interval } from "luxon"; import { documentationsForCoursebook } from "./coursebook.graphql"; import CoursebookFilters from "./CoursebookFilters.vue"; import CoursebookLoader from "./CoursebookLoader.vue"; import CoursebookEmptyMessage from "./CoursebookEmptyMessage.vue"; export default { name: "Coursebook", components: { CoursebookEmptyMessage, CoursebookFilters, CoursebookLoader, CRUDIterator, DateSelectFooter, DocumentationModal, }, props: { filterType: { type: String, required: true, }, objId: { type: [Number, String], required: false, default: null, }, objType: { type: String, required: false, default: null, }, }, data() { return { gqlQuery: documentationsForCoursebook, currentDate: "", visible: [], knownDates: {}, lastQuery: null, dateStart: "", dateEnd: "", // Placeholder values while query isn't completed yet groups: [], courses: [], incomplete: false, }; }, computed: { // Assertion: Should only fire on page load or selection change. // Resets date range. gqlQueryArgs() { console.log('computing gqlQueryArgs'); return { own: this.filterType === "all" ? false : true, objId: this.objId ? Number(this.objId) : undefined, objType: this.objType?.toUpperCase(), dateStart: this.dateStart, dateEnd: this.dateEnd, incomplete: !!this.incomplete, }; }, filters: { get() { return { objType: this.objType, objId: this.objId, filterType: this.filterType, incomplete: this.incomplete, }; }, set(selectedFilters) { if (Object.hasOwn(selectedFilters, "incomplete")) { this.incomplete = selectedFilters.incomplete; } else if ( Object.hasOwn(selectedFilters, "filterType") || Object.hasOwn(selectedFilters, "objId") || Object.hasOwn(selectedFilters, "objType") ) { this.$router.push({ name: "alsijil.coursebook", params: { filterType: selectedFilters.filterType ? selectedFilters.filterType : this.filterType, objType: selectedFilters.objType, objId: selectedFilters.objId, }, hash: this.$route.hash, }); // computed should not have side effects // but this was actually done before filters was refactored into // its own component this.resetDate(); } }, }, }, methods: { // TODO: Scroll to actual first date! resetDate() { // Assure current date console.log('Resetting date range', this.$route.hash); if (!this.$route.hash) { console.log('Set default date'); this.$router.replace({ hash: DateTime.now().toISODate() }) } // Resetting known dates to dateRange around current date this.knownDates = {}; this.currentDate = this.$route.hash.substring(1); const dateRange = this.dateRange(DateTime.fromISO(this.$route.hash.substring(1))); dateRange.forEach((ts) => this.knownDates[ts] = true); const lastIdx = dateRange.length - 1; // Returning a dateRange each around first & last date for the initial query this.dateStart = this.dateRange(dateRange[0])[0].toISODate(); this.dateEnd = this.dateRange(dateRange[lastIdx])[lastIdx].toISODate(); }, groupDocsByDay(docs) { // => {dt: [dt doc ...] ...} const docsByDay = docs.reduce((byDay, doc) => { // This works with dummy. Does actual doc have dateStart instead? const day = DateTime.fromISO(doc.datetimeStart).startOf("day"); byDay[day] ??= {date: day, docs: [], first: false, last: false}; byDay[day]['docs'].push(doc); return byDay; }, {}); // => [[dt doc ...] ...] return Object.keys(docsByDay) .sort() .map((key, i, {length}) => { const day = docsByDay[key]; if (i === 0) { day['first'] = true; } else if (i === length - 1) { day['last'] = true; } return day; }); // sorting is necessary since backend can send docs unordered }, /** * @param {"prev"|"next"} direction */ handleDateMove(direction) { const dateStartParsed = DateTime.fromISO(this.dateStart); const dateEndParsed = DateTime.fromISO(this.dateEnd); const dateParsed = DateTime.fromISO(this.date); const newDate = direction === "prev" ? dateParsed.minus({ days: 1 }) : dateParsed.plus({ days: 1 }); /* TODO: Everything below this line is also needed for when a date is selected via the calendar. → probably move this into a different function and create a second event listener for the input event. */ // Load 3 days into the future/past if (dateStartParsed >= newDate) { this.dateStart = newDate.minus({ days: 3 }).toISODate(); } if (dateEndParsed <= newDate) { this.dateEnd = newDate.plus({ days: 3 }).toISODate(); } this.$router.push({ name: "alsijil.coursebook", params: { filterType: this.filterType, objType: this.objType, objId: this.objId, }, }); // Define the function to find the nearest ID const ids = Array.from( document.querySelectorAll("[id^='documentation_']"), ).map((el) => el.id); // TODO: This should only be done after loading the new data const nearestId = this.findNearestId(newDate, direction, ids); this.$vuetify.goTo("#" + nearestId); }, findNearestId(targetDate, direction, ids) { const sortedIds = ids .map((id) => DateTime.fromISO(id.split("_")[1])) .sort((a, b) => a - b); if (direction === "prev") { sortedIds.reverse(); } const nearestId = sortedIds.find((id) => direction === "next" ? id >= targetDate : id <= targetDate, ) || sortedIds[sortedIds.length - 1]; return "documentation_" + nearestId.toISODate(); }, dateRange(date) { return Interval .fromDateTimes(date.minus({ days: 3 }), date.plus({ days: 4 })) .splitBy({ days: 1 }) .map((ts) => ts.start); }, // docsByDay: {dt: [dt doc ...] ...} assureDate(date) { if (!this.knownDates[date]) { console.log(this.lastQuery); console.log('unknown date', date.toISODate()); console.log(this.knownDates); // find missing & fetch missing range const missing = this.dateRange(date) .filter((ts) => !this.knownDates[ts]); // ask for first to last this.lastQuery.fetchMore({ variables: { dateStart: missing[0].toISODate(), dateEnd: missing[missing.length - 1].toISODate(), }, // Transform the previous result with new data updateQuery: (previousResult, { fetchMoreResult }) => { console.log('previousResult', previousResult); console.log('fetchMoreResult', fetchMoreResult); return { items: [...previousResult.items, ...fetchMoreResult.items.filter((doc) => { return previousResult.items.find((prev) => prev.id === doc.id) }), ], }; } }) // integrate into docsByDay } }, gotoDate(date, scroll) { // show this.$router.replace({ hash: date }) console.log('hash', this.$route.hash); // assure this.assureDate(DateTime.fromISO(date)); // scroll }, onIntersect(entries, observer) { const entry = entries[0]; if (entry.isIntersecting) { // coming console.log('intersect', this.visible); // track visible if (this.visible[0] > entry.target.dataset.date || this.visible.length === 0) { // coming is new first (top) date this.visible.unshift(entry.target.dataset.date); console.log('current', this.visible[0]); } else if (this.visible[this.visible.length -1] < entry.target.dataset.date) { // coming is new last (bottom) date this.visible.push(entry.target.dataset.date); } // load more if (entry.target.dataset.first) { console.log('load up'); } else if (entry.target.dataset.last) { console.log('load down'); } } else if (this.visible[0] === entry.target.dataset.date) { // first (top) visible date is going this.visible.shift() console.log('current', this.visible[0]); } else if (this.visible[this.visible.length - 1] === entry.target.dataset.date) { // last (bottom) visible date is going this.visible.pop() } }, }, created() { this.resetDate(); }, }; </script> <style> .max-width { max-width: 25rem; } </style>