diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue index 9168b2e4f41ef56983f64fe41dc28526985ba776..af143a59af433bba0d8ed87fa8beccabbbac6dfe 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue @@ -6,9 +6,10 @@ :enable-create="false" :enable-edit="false" :elevated="false" - :items-per-page="-1" @lastQuery="lastQuery = $event" ref="iterator" + fixed-header + disable-pagination hide-default-footer use-deep-search > @@ -16,33 +17,35 @@ <coursebook-filters v-model="filters" /> </template> <template #default="{ items }"> - <v-list-item - v-for="day in groupDocsByDay(items)" - two-line - :key="'day-' + day[0]" - > - <v-list-item-content :id="'documentation_' + day[0].toISODate()"> - <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 day.slice(1)" - :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> + <coursebook-loader /> + <coursebook-day + v-for="{ date, docs, first, last } in groupDocsByDay(items)" + v-intersect="{ + handler: intersectHandler(date, first, last), + options: { + rootMargin: '-' + topMargin + 'px 0px 0px 0px', + threshold: [0, 1], + }, + }" + :date="date" + :docs="docs" + :lastQuery="lastQuery" + :focus-on-mount="initDate && initDate.toMillis() === date.toMillis()" + @init="transition" + :key="'day-' + date" + ref="days" + /> + <coursebook-loader /> - <date-select-footer :value="date" @click="handleDateMove" /> + <date-select-footer + :value="currentDate" + @input="gotoDate" + @prev="gotoPrev" + @next="gotoNext" + /> </template> <template #loading> - <CoursebookLoader /> + <coursebook-loader :number-of-days="10" :number-of-docs="5" /> </template> <template #no-data> @@ -64,8 +67,8 @@ <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 } from "luxon"; +import CoursebookDay from "./CoursebookDay.vue"; +import { DateTime, Interval } from "luxon"; import { documentationsForCoursebook } from "./coursebook.graphql"; import CoursebookFilters from "./CoursebookFilters.vue"; import CoursebookLoader from "./CoursebookLoader.vue"; @@ -79,7 +82,7 @@ export default { CoursebookLoader, CRUDIterator, DateSelectFooter, - DocumentationModal, + CoursebookDay, }, props: { filterType: { @@ -96,36 +99,51 @@ export default { required: false, default: null, }, - // ISODate - date: { - type: String, + /** + * Number of consecutive to load at once + * This number of days is initially loaded and loaded + * incrementally while scrolling. + */ + dayIncrement: { + type: Number, required: false, - default: "", + default: 7, + }, + /** + * Margin from coursebook list to top of viewport in pixels + */ + topMargin: { + type: Number, + required: false, + default: 165, }, }, data() { return { gqlQuery: documentationsForCoursebook, lastQuery: null, + dateStart: "", + dateEnd: "", // Placeholder values while query isn't completed yet groups: [], courses: [], - dateStart: null, - dateEnd: null, incomplete: false, + ready: false, + initDate: false, + currentDate: "", + hashUpdater: false, }; }, computed: { + // Assertion: Should only fire on page load or selection change. + // Resets date range. gqlQueryArgs() { return { - // Assure courseId is a number own: this.filterType === "all" ? false : true, - objId: this.objId ? Number(this.objId) : null, + objId: this.objId ? Number(this.objId) : undefined, objType: this.objType?.toUpperCase(), - dateStart: this.dateStart ?? this.date, - dateEnd: - this.dateEnd ?? - DateTime.fromISO(this.date).plus({ weeks: 1 }).toISODate(), + dateStart: this.dateStart, + dateEnd: this.dateEnd, incomplete: !!this.incomplete, }; }, @@ -147,103 +165,196 @@ export default { Object.hasOwn(selectedFilters, "objType") ) { this.$router.push({ - name: "alsijil.coursebook_by_type_and_date", + name: "alsijil.coursebook", params: { filterType: selectedFilters.filterType ? selectedFilters.filterType : this.filterType, objType: selectedFilters.objType, objId: selectedFilters.objId, - date: this.date, }, + 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(); + // might skip query until both set = atomic } }, }, }, methods: { - // => [[dt doc ...] ...] + resetDate(toDate) { + // Assure current date + console.log("Resetting date range", this.$route.hash); + this.currentDate = toDate || this.$route.hash?.substring(1); + if (!this.currentDate) { + console.log("Set default date"); + this.setDate(DateTime.now().toISODate()); + } + + const date = DateTime.fromISO(this.currentDate); + this.initDate = date; + this.dateStart = date.minus({ days: this.dayIncrement }).toISODate(); + this.dateEnd = date.plus({ days: this.dayIncrement }).toISODate(); + }, + transition() { + this.initDate = false; + this.ready = true; + }, groupDocsByDay(docs) { - const byDay = docs.reduce((byDay, doc) => { + // => {dt: {date: dt, docs: 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] ??= [day]; - byDay[day].push(doc); + byDay[day] ??= { date: day, docs: [] }; + byDay[day].docs.push(doc); return byDay; }, {}); - - return Object.keys(byDay) + // => [{date: dt, docs: doc ..., idx: idx, lastIdx: last-idx} ...] + // sorting is necessary since backend can send docs unordered + return Object.keys(docsByDay) .sort() - .map((key) => byDay[key]); + .map((key, idx, { length }) => { + const day = docsByDay[key]; + day.first = idx === 0; + const lastIdx = length - 1; + day.last = idx === lastIdx; + return day; + }); }, - /** - * @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_by_type_and_date", - params: { - filterType: this.filterType, - objType: this.objType, - objId: this.objId, - date: newDate.toISODate(), + // docsByDay: {dt: [dt doc ...] ...} + fetchMore(from, to, then) { + console.log("fetching", from, to); + this.lastQuery.fetchMore({ + variables: { + dateStart: from, + dateEnd: to, + }, + // Transform the previous result with new data + updateQuery: (previousResult, { fetchMoreResult }) => { + console.log("Received more"); + then(); + return { items: previousResult.items.concat(fetchMoreResult.items) }; }, }); - - // 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(); + }, + setDate(date) { + this.currentDate = date; + if (!this.hashUpdater) { + this.hashUpdater = window.requestIdleCallback(() => { + if (!(this.$route.hash.substring(1) === this.currentDate)) { + this.$router.replace({ hash: this.currentDate }); + } + this.hashUpdater = false; + }); } + }, + fixScrollPos(height, top) { + this.$nextTick(() => { + if (height < document.documentElement.scrollHeight) { + document.documentElement.scrollTop = + document.documentElement.scrollHeight - height + top; + this.ready = true; + } else { + // Update top, could have changed in the meantime. + this.fixScrollPos(height, document.documentElement.scrollTop); + } + }); + }, + intersectHandler(date, first, last) { + let once = true; + return (entries) => { + const entry = entries[0]; + if (entry.isIntersecting) { + if (entry.boundingClientRect.top <= this.topMargin || first) { + console.log("@ ", date.toISODate()); + this.setDate(date.toISODate()); + } - const nearestId = - sortedIds.find((id) => - direction === "next" ? id >= targetDate : id <= targetDate, - ) || sortedIds[sortedIds.length - 1]; + if (once && this.ready && first) { + console.log("load up", date.toISODate()); + this.ready = false; + this.fetchMore( + date.minus({ days: this.dayIncrement }).toISODate(), + date.minus({ days: 1 }).toISODate(), + () => { + this.fixScrollPos( + document.documentElement.scrollHeight, + document.documentElement.scrollTop, + ); + }, + ); + once = false; + } else if (once && this.ready && last) { + console.log("load down", date.toISODate()); + this.ready = false; + this.fetchMore( + date.plus({ days: 1 }).toISODate(), + date.plus({ days: this.dayIncrement }).toISODate(), + () => { + this.ready = true; + }, + ); + once = false; + } + } + }; + }, + // Improve me? + // The navigation logic could be a bit simpler if the current days + // where known as a sorted array (= result of groupDocsByDay) But + // then the list would need its own component and this gets rather + // complicated. Then the calendar could also show the present days + // / gray out the missing. + // + // Next two: arg date is ts object + findPrev(date) { + return this.$refs.days + .map((day) => day.date) + .sort() + .reverse() + .find((date2) => date2 < date); + }, + findNext(date) { + return this.$refs.days + .map((day) => day.date) + .sort() + .find((date2) => date2 > date); + }, + gotoDate(date) { + const present = this.$refs.days.find( + (day) => day.date.toISODate() === date, + ); - return "documentation_" + nearestId.toISODate(); + if (present) { + // React immediatly -> smoother navigation + // Also intersect handler does not always react to scrollIntoView + this.setDate(date); + present.focus("smooth"); + } else if ( + !this.findPrev(DateTime.fromISO(date)) || + !this.findNext(DateTime.fromISO(date)) + ) { + this.resetDate(date); + } + }, + gotoPrev() { + const prev = this.findPrev(DateTime.fromISO(this.currentDate)); + if (prev) { + this.gotoDate(prev.toISODate()); + } + }, + gotoNext() { + const next = this.findNext(DateTime.fromISO(this.currentDate)); + if (next) { + this.gotoDate(next.toISODate()); + } }, }, - mounted() { - this.dateStart = this.date; - this.dateEnd = DateTime.fromISO(this.dateStart) - .plus({ weeks: 1 }) - .toISODate(); + created() { + this.resetDate(); }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue new file mode 100644 index 0000000000000000000000000000000000000000..14411e9ce9c1c2780402890a80aa907b246262ff --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue @@ -0,0 +1,66 @@ +<template> + <v-list-item :style="{ scrollMarginTop: '145px' }" two-line> + <v-list-item-content> + <v-subheader class="text-h6">{{ + $d(date, "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> +</template> + +<script> +import DocumentationModal from "./documentation/DocumentationModal.vue"; +export default { + name: "CoursebookDay", + components: { + DocumentationModal, + }, + props: { + date: { + type: Object, + required: true, + }, + docs: { + type: Array, + required: true, + }, + lastQuery: { + type: Object, + required: true, + }, + focusOnMount: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ["init"], + methods: { + focus(how) { + this.$el.scrollIntoView({ + behavior: how, + block: "start", + inline: "nearest", + }); + console.log("focused @", this.date.toISODate()); + }, + }, + mounted() { + if (this.focusOnMount) { + this.$nextTick(this.focus("instant")); + this.$emit("init"); + } + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue index a99df0588b779a7643b5ae66617132110c49b67c..a5136eb9f0c2535009fd78a02e7481aebacd12b4 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue @@ -1,12 +1,12 @@ <template> <div> - <v-list-item v-for="i in 10" :key="'i-' + i"> + <v-list-item v-for="i in numberOfDays" :key="'i-' + i"> <v-list-item-content> <v-list-item-title> <v-skeleton-loader type="heading" /> </v-list-item-title> <v-list max-width="100%"> - <v-list-item v-for="j in 5" :key="'j-' + j"> + <v-list-item v-for="j in numberOfDocs" :key="'j-' + j"> <DocumentationLoader /> </v-list-item> </v-list> @@ -20,5 +20,17 @@ import DocumentationLoader from "./documentation/DocumentationLoader.vue"; export default { name: "CoursebookLoader", components: { DocumentationLoader }, + props: { + numberOfDays: { + type: Number, + required: false, + default: 1, + }, + numberOfDocs: { + type: Number, + required: false, + default: 1, + }, + }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/index.js b/aleksis/apps/alsijil/frontend/index.js index 6cfb71044ee40390b0ae99509b8ec7a2b6eba0fa..1ec79280e06ef377c4be215c27b5291c51043522 100644 --- a/aleksis/apps/alsijil/frontend/index.js +++ b/aleksis/apps/alsijil/frontend/index.js @@ -60,14 +60,14 @@ export default { component: () => import("./components/coursebook/Coursebook.vue"), redirect: () => { return { - name: "alsijil.coursebook_by_type_and_date", + name: "alsijil.coursebook", params: { - date: DateTime.now().toISODate(), filterType: "my", }, + hash: "#" + DateTime.now().toISODate(), }; }, - name: "alsijil.coursebook", + name: "alsijil.coursebook_landing", props: true, meta: { inMenu: true, @@ -79,9 +79,9 @@ export default { }, children: [ { - path: ":date(\\d\\d\\d\\d-\\d\\d-\\d\\d)/:filterType(my|all)/:objType(group|course|teacher)?/:objId(\\d+)?/", + path: ":filterType(my|all)/:objType(group|course|teacher)?/:objId(\\d+)?/", component: () => import("./components/coursebook/Coursebook.vue"), - name: "alsijil.coursebook_by_type_and_date", + name: "alsijil.coursebook", meta: { titleKey: "alsijil.coursebook.menu_title", toolbarTitle: "alsijil.coursebook.menu_title",