diff --git a/application/controllers/Tempus.php b/application/controllers/Tempus.php
index a10c74909..2b72bece5 100644
--- a/application/controllers/Tempus.php
+++ b/application/controllers/Tempus.php
@@ -55,6 +55,11 @@ class Tempus extends Auth_Controller
$this->_loadView(array('organisationen' => $organisationen, 'studiensemestern' => $studiensemestern));
}
+ public function preview()
+ {
+ $this->_loadView();
+ }
+
private function _loadView($extraVariables = [])
{
$this->load->view('Tempus', [
diff --git a/application/libraries/CollisionChecker.php b/application/libraries/CollisionChecker.php
index 9338745bc..f3ffb056e 100644
--- a/application/libraries/CollisionChecker.php
+++ b/application/libraries/CollisionChecker.php
@@ -16,14 +16,14 @@ class CollisionChecker
$this->_ci->load->library('collision/checks/RoomCollisionCheck');
$this->_ci->load->library('collision/checks/LectureCollisionCheck');
+ $this->_ci->load->library('collision/checks/ResourcesCollisionCheck');
$this->_ci->load->library('collision/checks/VerbandCollisionCheck');
$this->_ci->load->library('collision/checks/StudentCollisionCheck');
- $this->_ci->load->library('collision/checks/ResourcesCollisionCheck');
$this->register($this->_ci->roomcollisioncheck);
$this->register($this->_ci->lecturecollisioncheck);
+ $this->register($this->_ci->resourcescollisioncheck);
$this->register($this->_ci->verbandcollisioncheck);
$this->register($this->_ci->studentcollisioncheck);
- $this->register($this->_ci->resourcescollisioncheck);
Events::trigger('collision_register', $this);
}
@@ -50,41 +50,28 @@ class CollisionChecker
}
public function runAll($kalender_ids)
- {
- $results = array_fill_keys($kalender_ids, []);
-
- foreach ($this->_checks as $check)
- {
- $batchResult = $check->checkAll($kalender_ids);
- foreach ($batchResult as $kalender_id => $errors)
- {
- $results[$kalender_id] = array_merge($results[$kalender_id], $errors);
- }
- }
-
- return $results;
- }
-
- public function runAny($kalender_ids)
{
$remaining_ids = array_values(array_unique($kalender_ids));
+ $results = array_fill_keys($remaining_ids, []);
$collision_ids = [];
foreach ($this->_checks as $check)
{
if (empty($remaining_ids)) break;
-
- $batch_result = $check->checkAll($remaining_ids);
- foreach ($batch_result as $kalender_id => $errors)
+ log_message('error', 'CollisionChecker: Running check ' . $check->getName() . ' for ' . count($remaining_ids) . ' remaining kalender_ids');
+ $batchResult = $check->checkAll($remaining_ids);
+ foreach ($batchResult as $kalender_id => $errors)
{
- if (!empty($errors))
- $collision_ids[$kalender_id] = true;
+ if (empty($errors)) continue;
+
+ $results[$kalender_id] = array_merge($results[$kalender_id], $errors);
+ $collision_ids[$kalender_id] = true;
}
if (!empty($collision_ids))
$remaining_ids = array_values(array_diff($remaining_ids, array_keys($collision_ids)));
}
- return $collision_ids;
+ return $results;
}
}
diff --git a/application/libraries/KalenderLib.php b/application/libraries/KalenderLib.php
index 382af26c1..d49e37a4d 100644
--- a/application/libraries/KalenderLib.php
+++ b/application/libraries/KalenderLib.php
@@ -845,7 +845,7 @@ class KalenderLib
foreach ($kalender_id_batches as $kalender_ids)
{
- $collision_ids = $this->_ci->collisionchecker->runAny($kalender_ids);
+ $collision_ids = $this->_ci->collisionchecker->runAll($kalender_ids);
foreach ($collision_ids as $kalender_id => $has_collision)
{
diff --git a/public/js/apps/Tempus.js b/public/js/apps/Tempus.js
index b1159da02..de8b31b3d 100644
--- a/public/js/apps/Tempus.js
+++ b/public/js/apps/Tempus.js
@@ -16,6 +16,7 @@
*/
import FhcTempus from "../components/Tempus/Tempus.js";
+import FhcTempusCalendarPreview from "../components/Tempus/TempusCalendarPreview.js";
import FhcTempusSync from "../components/Tempus/TempusSync.js";
import Phrasen from "../plugins/Phrasen.js";
@@ -28,6 +29,7 @@ const router = VueRouter.createRouter({
history: VueRouter.createWebHistory(),
routes: [
{ path: `/${ciPath}/Tempus`, component: FhcTempus },
+ { path: `/${ciPath}/Tempus/preview`, component: FhcTempusCalendarPreview },
{ path: `/${ciPath}/Tempus/sync`, component: FhcTempusSync },
]
});
diff --git a/public/js/components/Calendar/Mode/Range.js b/public/js/components/Calendar/Mode/Range.js
index 29693aa0d..d51e4a5d5 100644
--- a/public/js/components/Calendar/Mode/Range.js
+++ b/public/js/components/Calendar/Mode/Range.js
@@ -16,6 +16,9 @@ export default {
rangeViewPresets: {
default: {},
},
+ rangeViewPreviewLink: {
+ default: null,
+ },
},
props: {
currentDate: {
@@ -133,12 +136,13 @@ export default {
@cal-click-default.capture="handleClickDefaults"
>
-
{{ rangeViewPresets.label }}
+
{{ rangeViewPresets.label }}
+
+
+
diff --git a/public/js/components/Calendar/Tempus.js b/public/js/components/Calendar/Tempus.js
index 693ec6e85..e53f57e32 100644
--- a/public/js/components/Calendar/Tempus.js
+++ b/public/js/components/Calendar/Tempus.js
@@ -10,6 +10,17 @@ import ApiKalender from '../../api/factory/tempus/kalender.js';
import draggable from '../../directives/draggable.js';
import ApiStudiensemester from '../../api/factory/studiensemester.js';
+function getRangeLength(range) {
+ if (!(range instanceof luxon.Interval) || !range.isValid)
+ return null;
+
+ const start = range.start.startOf('day');
+ const end = range.end.startOf('day');
+ if (end < start) return null;
+
+ return Math.floor(end.diff(start, 'days').days) + 1;
+}
+
export default {
name: 'CalendarTempus',
components: {
@@ -19,6 +30,7 @@ export default {
return {
rangeLength: Vue.computed(() => this.currentRangeLength),
rangeViewPresets: Vue.computed(() => this.semesterRangePresets),
+ rangeViewPreviewLink: Vue.computed(() => this.rangeViewPreviewLink),
// rangeViewSelectedPreset: Vue.computed({
// get: () => this.selectedRangePreset,
// set: value => this.selectedRangePreset = value,
@@ -61,10 +73,18 @@ export default {
type: [Date, String, Number, luxon.DateTime],
default: luxon.DateTime.local(),
},
+ range: {
+ type: luxon.Interval,
+ default: null,
+ },
mode: {
type: String,
default: 'Week',
},
+ modes: {
+ type: Array,
+ default: () => ['week', 'month', 'tableList', 'range'],
+ },
getPromiseFunc: {
type: Function,
required: true,
@@ -92,6 +112,22 @@ export default {
showEvents: {
type: Boolean,
default: true
+ },
+ isEventDraggingEnabled: {
+ type: Boolean,
+ default: true,
+ },
+ isEventResizingEnabled: {
+ type: Boolean,
+ default: true,
+ },
+ isRangeVirtualScrollEnabled: {
+ type: Boolean,
+ default: true,
+ },
+ rangeViewPreviewLink: {
+ type: String,
+ default: null,
}
},
emits: [
@@ -111,7 +147,7 @@ export default {
visibleDates: null,
eventReloadKey: 0,
refreshEventsAfterReload: false,
- modes: {
+ calendarModes: {
week: Vue.markRaw(ModeWeek),
month: Vue.markRaw(ModeMonth),
tableList: Vue.markRaw(ModeTable),
@@ -130,7 +166,7 @@ export default {
teachingunits: null,
hoursplan: null,
showRaster: true,
- currentRangeLength: this.rangeLength,
+ currentRangeLength: getRangeLength(this.range) ?? this.rangeLength,
selectedRangePreset: null, //this.currentSemester, TODO: This is a hack to make the range mode work, but it should be fixed in the future
semesterRangePresets: {
label: null,
@@ -139,6 +175,10 @@ export default {
};
},
watch: {
+ range(range) {
+ const rangeLength = getRangeLength(range);
+ this.currentRangeLength = rangeLength ?? this.rangeLength;
+ },
events() {
if (this.refreshEventsAfterReload) {
this.eventReloadKey += 1;
@@ -149,6 +189,14 @@ export default {
}
},
computed: {
+ availableModes() {
+ return this.modes.reduce((availableModes, mode) => {
+ if (this.calendarModes[mode])
+ availableModes[mode] = this.calendarModes[mode];
+
+ return availableModes;
+ }, {});
+ },
backgrounds() {
let now = luxon.DateTime.now().setZone(this.timezone);
@@ -180,7 +228,11 @@ export default {
// that is visible inside the slider viewport, including a one-day buffer
// before and after the visible dates.
console.log('visibleDates', this.visibleDates);
- if (this.currentMode === 'range' && Array.isArray(this.visibleDates))
+ if (
+ this.isRangeVirtualScrollEnabled
+ && this.currentMode === 'range'
+ && Array.isArray(this.visibleDates)
+ )
{
const visibleIntervals = this.visibleDates
.map(date => luxon.Interval.fromDateTimes(
@@ -360,7 +412,7 @@ export default {
ref="calendar"
class="fhc-calendar-lvplan"
:date="date"
- :modes="modes"
+ :modes="availableModes"
:mode-options="modeOptions"
:mode="mode"
:timezone="timezone"
@@ -371,10 +423,10 @@ export default {
:time-grid="showRaster ? teachingunits : null"
:hours-plan="hoursplan"
show-btns
- :draggable-events="true"
- :resizable-events="true"
- :on-drop="['week', 'range'].includes(currentMode) ? ondrop : null"
- :on-resize="onresize"
+ :draggable-events="isEventDraggingEnabled"
+ :resizable-events="isEventResizingEnabled"
+ :on-drop="isEventDraggingEnabled && ['week', 'range'].includes(currentMode) ? ondrop : null"
+ :on-resize="isEventResizingEnabled ? onresize : null"
@update:date="handleDateUpdate"
@update:mode="(newMode, newDate) => { currentMode = newMode; $emit('update:mode', newMode, newDate) }"
@update:range="updateRange"
@@ -418,6 +470,7 @@ export default {
Stundenraster
lecture.showEvents)
.map((lecture) => lecture.uid);
},
+ rangeViewPreviewLink() {
+ const previewUrl = `${this.tempusRoot.replace(/\/$/, '')}/preview`;
+ if (!this.lastRange?.start || !this.lastRange?.end)
+ return previewUrl;
+
+ const params = new URLSearchParams({
+ startDate: this.lastRange.start.toISODate(),
+ endDate: this.lastRange.end.toISODate(),
+ });
+
+ return `${previewUrl}?${params}`;
+ },
keyboardShortcuts() {
return getTempusShortcuts(this);
},
@@ -1221,6 +1233,7 @@ export default {
:visible-status="visibleStatus"
:date="calendarDate"
:mode="currentMode"
+ :range-view-preview-link="rangeViewPreviewLink"
:parkedEvents="parkedKeys"
:visible-lecturers="visibleLecturerUids"
:show-events="showEvents"
diff --git a/public/js/components/Tempus/TempusCalendarPreview.js b/public/js/components/Tempus/TempusCalendarPreview.js
new file mode 100644
index 000000000..c65a91286
--- /dev/null
+++ b/public/js/components/Tempus/TempusCalendarPreview.js
@@ -0,0 +1,663 @@
+/**
+ * Copyright (C) 2024 fhcomplete.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
.
+ */
+import FhcCalendar from '../Calendar/Tempus.js';
+import ApiKalender from '../../api/factory/tempus/kalender.js';
+import ApiRenderers from '../../api/factory/renderers.js';
+import ApiTempusConfig from '../../api/factory/tempus/config.js';
+
+function getRangeFromQuery(query, timezone) {
+ const startDate = Array.isArray(query?.startDate)
+ ? query.startDate[0]
+ : query?.startDate;
+ const endDate = Array.isArray(query?.endDate)
+ ? query.endDate[0]
+ : query?.endDate;
+ const start = luxon.DateTime.fromISO(startDate ?? '', {
+ zone: timezone,
+ }).startOf('day');
+ const end = luxon.DateTime.fromISO(endDate ?? '', {
+ zone: timezone,
+ }).endOf('day');
+
+ if (!start.isValid || !end.isValid || end < start)
+ return null;
+
+ return luxon.Interval.fromDateTimes(start, end);
+}
+
+export default {
+ name: 'TempusCalendarPreview',
+ components: {
+ FhcCalendar,
+ },
+ props: {
+ defaultSemester: String,
+ config: Object,
+ permissions: Object,
+ tempusRoot: String,
+ cisRoot: String,
+ activeAddons: String, // semicolon separated list of active addons
+ viewData: Object,
+ logoutUrl: String,
+ avatarUrl: String,
+ },
+ provide() {
+ return {
+ cisRoot: this.cisRoot,
+ defaultSemester: this.defaultSemester,
+ currentSemester: this.defaultSemester,
+ renderers: Vue.computed(() => this.renderers),
+ appConfig: Vue.computed(() => this.appconfig),
+ canToggleGrid: this.permissions.stundenraster,
+ };
+ },
+ data() {
+ const today = luxon.DateTime.now().setZone(this.config.timezone).toISODate();
+
+ return {
+ appconfig: {},
+ currentMode: 'range',
+ configEndpoints: ApiTempusConfig,
+ hoveredEvent: null,
+ renderers: {},
+ ort_kurzbz: null,
+ parkedKeys: new Set(),
+ lecturers: [],
+ studiengaenge: [],
+ rooms: [],
+ overlayCache: {},
+ extraBackgrounds: [],
+ lastRange: null,
+ visibleStatus: ['all'],
+ selectedStudiensemester: this.defaultSemester,
+ urlRange: null,
+ calendarDatesByMode: {
+ week: today,
+ month: today,
+ tableList: today,
+ range: today,
+ },
+ historyEntries: [],
+ previewRole: 'planer',
+ multiWeekModal: {
+ show: false,
+ lehreinheitId: null,
+ ortKurzbz: null,
+ startTime: null,
+ endTime: null,
+ },
+ studiengaenge_all: [],
+ maxDailyEventLimitForMonthView: 10,
+ raumvorschlagPreview: null,
+ raumvorschlagLoading: false,
+ showEvents: true,
+ reservierungPending: false,
+ bcc: null,
+ currentlyUpdatedEvent: null,
+ };
+ },
+ computed: {
+ calendarDate() {
+ return (
+ this.calendarDatesByMode[this.currentMode] ??
+ this.calendarDatesByMode.week
+ );
+ },
+ visibleLecturerUids() {
+ if (!this.lecturers.length) return null;
+ return this.lecturers
+ .filter((lecture) => lecture.showEvents)
+ .map((lecture) => lecture.uid);
+ },
+ },
+ methods: {
+ handleChangeDate(newDate) {
+ if (!(newDate && luxon.DateTime.isDateTime(newDate) && newDate.isValid))
+ return;
+
+ this.calendarDatesByMode = {
+ ...this.calendarDatesByMode,
+ [this.currentMode]: newDate.toISODate(),
+ };
+ },
+ handleChangeMode(newMode, newDate) {
+ if (!newMode) return;
+
+ if (newDate && luxon.DateTime.isDateTime(newDate) && newDate.isValid) {
+ this.calendarDatesByMode = {
+ ...this.calendarDatesByMode,
+ [this.currentMode]: newDate.toISODate(),
+ };
+ }
+
+ this.currentMode = newMode;
+ },
+ getPromiseFunc(start, end) {
+ const collisionCheck = false;
+ const hasRooms = this.rooms.length > 0;
+ const hasLektoren = this.lecturers.length > 0;
+ const hasStg = this.studiengaenge.length > 0;
+
+ const filter = {};
+
+ if (hasRooms) filter.ort = this.rooms.map((room) => room.ort_kurzbz);
+ if (hasStg) {
+ filter.stg = this.studiengaenge.map(
+ ({ stg_kz, semester, orgform_kurzbz }) => ({
+ stg_kz,
+ semester,
+ orgform_kurzbz,
+ }),
+ );
+ }
+ if (hasLektoren)
+ filter.uid = this.lecturers.map((lecture) => lecture.uid);
+
+ let response = null;
+ if (this.previewRole === 'lektor')
+ response = [
+ this.$api.call(
+ ApiKalender.getPlanLecturer(
+ start.toISODate(),
+ end.toISODate(),
+ collisionCheck,
+
+ ),
+ ),
+ ];
+
+ if (this.previewRole === 'student')
+ response = [
+ this.$api.call(
+ ApiKalender.getPlanStudent(
+ start.toISODate(),
+ end.toISODate(),
+ collisionCheck,
+
+ ),
+ ),
+ ];
+
+ response = [
+ this.$api.call(
+ ApiKalender.getPlan(
+ filter,
+ start.toISODate(),
+ end.toISODate(),
+ collisionCheck,
+
+ ),
+ ),
+ ];
+
+ if (response) {
+ response[0].then((result) => {
+ this.scrollToAndEmphasizeUpdatedEvent();
+ });
+ }
+
+ return response;
+ },
+ _parseDates(start, end) {
+ const startDT = luxon.DateTime.fromISO(start);
+ const endDT = luxon.DateTime.fromISO(end);
+
+ if (!startDT.isValid || !endDT.isValid) {
+ alert('Ungültiges Datum');
+ return null;
+ }
+
+ return {
+ startDT,
+ endDT,
+ start_time: startDT.toFormat('yyyy-MM-dd HH:mm'),
+ end_time: endDT.toFormat('yyyy-MM-dd HH:mm'),
+ };
+ },
+
+ _updateKalenderEvent(obj, startDT, endDT, start_time, end_time, onSuccess) {
+ const origStart = luxon.DateTime.fromISO(obj.orig.isostart);
+ const origEnd = luxon.DateTime.fromISO(obj.orig.isoend);
+
+ if (
+ origStart.toMillis() === startDT.toMillis() &&
+ origEnd.toMillis() === endDT.toMillis()
+ )
+ return;
+
+ const updatedInfos = {
+ ort_kurzbz: this.rooms.length
+ ? this.rooms.map((room) => room.ort_kurzbz)
+ : (obj.orig.ort_kurzbz ?? []),
+ start_time,
+ end_time,
+ };
+
+ return this.$api
+ .call(
+ ApiKalender.updateKalenderEvent(obj.orig.kalender_id, updatedInfos),
+ )
+ .then(() => {
+ if (onSuccess) {
+ onSuccess();
+ this.$refs.calendar.$refs.calendar.$refs.mode.$refs.view.$refs.grid.disableAutoScroll();
+ this.currentlyUpdatedEvent = obj.orig;
+ }
+ })
+ .catch((error) => {
+ this.currentlyUpdatedEvent = null;
+ this.$refs.calendar.clearOutCalendarEventEmphasis();
+ this.$nextTick(() => {
+ this.$refs.calendar.reloadEvents();
+ });
+ throw error;
+ });
+ },
+ handleRange(range) {
+ if (!range?.start || !range?.end) return;
+
+ if (this.currentMode === 'week') {
+ //Workaround because, updateRange is emitting 2 times
+ const startDay = range.start.startOf('day');
+ const endDay = range.end.startOf('day');
+
+ const days = Math.round(endDay.diff(startDay, 'days').days) + 1;
+ if (days > 8) return;
+ }
+
+ this.lastRange = range;
+
+ const key = `${range.start.toISODate()}_${range.end.toISODate()}_${this.currentMode}`;
+
+ for (const lect of this.lecturers) {
+ this.getOverlays(lect.uid, range, key);
+ }
+
+ this.rebuildExtraBackgrounds();
+
+ if (this.raumvorschlagPreview)
+ this.previewRaumvorschlag({
+ lehreinheit_id: this.raumvorschlagPreview.lehreinheit_id,
+ });
+ },
+
+ getOverlays(uid, range, rangeKey) {
+ if (!this.overlayCache[uid]) this.overlayCache[uid] = {};
+
+ let entry = this.overlayCache[uid][rangeKey];
+
+ if (entry?.loaded || entry?.loading) return;
+
+ entry = this.overlayCache[uid][rangeKey] = {
+ blocks: [],
+ wishes: [],
+ loading: true,
+ loaded: false,
+ };
+
+ const promises = [];
+ const lect = this.lecturers.find((lecture) => lecture.uid === uid);
+
+ if (lect.overlays.wishes) {
+ promises.push(
+ this.$api
+ .call(
+ ApiKalender.getLektorZeitwuensche(
+ uid,
+ range.start.toISODate(),
+ range.end.toISODate(),
+ ),
+ )
+ .then((result) => {
+ entry.wishes = (result.data || []).map((zeitwunsch) => ({
+ class: `bg-lecturer-wish bg-uid-${uid} wish-w-${zeitwunsch.gewicht}`,
+ start: zeitwunsch.isostart,
+ end: zeitwunsch.isoend,
+ label: zeitwunsch.label,
+ }));
+ }),
+ );
+ }
+
+ if (lect.overlays.blocks) {
+ promises.push(
+ this.$api
+ .call(
+ ApiKalender.getLektorZeitsperren(
+ uid,
+ range.start.toISODate(),
+ range.end.toISODate(),
+ ),
+ )
+ .then((result) => {
+ entry.blocks = (result.data || []).map((zeitsperre) => ({
+ class: `bg-lecturer-block bg-uid-${uid}`,
+ start: zeitsperre.isostart,
+ end: zeitsperre.isoend,
+ label: zeitsperre.label,
+ }));
+ }),
+ );
+ }
+
+ Promise.allSettled(promises).then(() => {
+ entry.loading = false;
+ entry.loaded = true;
+ this.rebuildExtraBackgrounds();
+ });
+ },
+
+ rebuildExtraBackgrounds() {
+ if (!this.lastRange) return;
+
+ const key =
+ `${this.lastRange.start.toISODate()}_` +
+ `${this.lastRange.end.toISODate()}_` +
+ `${this.currentMode}`;
+ let res = [];
+
+ for (let lect of this.lecturers) {
+ const entry = this.overlayCache[lect.uid]?.[key];
+ if (!entry) continue;
+
+ if (lect.overlays.blocks) res.push(...(entry.blocks || []));
+
+ if (lect.overlays.wishes) res.push(...(entry.wishes || []));
+ }
+
+ if (this.raumvorschlagPreview)
+ res.push(...this.raumvorschlagPreview.slots);
+
+ this.extraBackgrounds = res;
+ },
+
+ openReservierung() {
+ this.reservierungPending = false;
+ this.$refs.reservierung?.show();
+ },
+ onEventHover(event) {
+ this.hoveredEvent = event;
+ },
+ onEventUnhover(event) {
+ if (this.hoveredEvent?.kalender_id === event?.kalender_id) {
+ this.hoveredEvent = null;
+ }
+ },
+ scrollToAndEmphasizeUpdatedEvent() {
+ if (!this.currentlyUpdatedEvent) return;
+
+ document
+ .querySelectorAll(
+ '.fhc-calendar-base-grid .fhc-calendar-base-grid-line-event',
+ )
+ .forEach((el) => {
+ const spinner = el.querySelector('.spinner-overlay');
+ if (spinner) {
+ spinner.remove();
+ }
+
+ el.classList.remove(
+ 'updating-event',
+ 'updated-event',
+ 'updated-event-long',
+ );
+ });
+
+ setTimeout(() => {
+ const eventEl = document.querySelector(
+ `[data-group-id="event-group-${this.currentlyUpdatedEvent.eindeutige_kalender_gruppen_id}"]`,
+ );
+ if (!eventEl) return;
+
+ const calendar = document.querySelector('.fhc-calendar-base-grid');
+ const eventRect = eventEl.getBoundingClientRect();
+
+ const offset = 300;
+
+ const isInsideScrolledView =
+ eventEl.offsetLeft < calendar.scrollLeft + calendar.clientWidth &&
+ eventEl.offsetLeft + eventEl.offsetWidth > calendar.scrollLeft &&
+ eventEl.offsetTop < calendar.scrollTop + calendar.clientHeight &&
+ eventEl.offsetTop + eventEl.offsetHeight > calendar.scrollTop;
+
+ const rect = eventEl.getBoundingClientRect();
+ if (!isInsideScrolledView) {
+ eventEl.scrollIntoView({
+ behavior: 'smooth',
+ inline: 'center',
+ block: 'nearest',
+ });
+ }
+
+ let timeout = 0;
+ let emphasizeUpdateClassName = isInsideScrolledView
+ ? 'updated-event'
+ : 'updated-event-long';
+
+ if (!isInsideScrolledView) timeout = 300;
+
+ setTimeout(() => {
+ eventEl.classList.add(emphasizeUpdateClassName);
+ }, timeout);
+
+ this.currentlyUpdatedEvent = null;
+ }, 100);
+ },
+ updateKalenderEventElementDisplay(calendarGruppenId, startDT, endDT) {
+ if (!calendarGruppenId)
+ return;
+
+ let startOfDay = startDT.startOf('day');
+ let newPotentialStart = startDT.diff(startOfDay).toMillis() ?? 1;
+ let newPotentialEnd = endDT.diff(startOfDay).toMillis();
+
+ const calendar = this.$refs.calendar?.$el;
+ let element = calendar?.querySelector(
+ `[data-group-id="event-group-${calendarGruppenId}"]`,
+ );
+ if (!element) return;
+
+ const targetGridLine = [...calendar.querySelectorAll(
+ '.fhc-calendar-base-grid-line',
+ )].find((gridLine) => {
+ const [rowStart, columnStart, rowEnd] = getComputedStyle(
+ gridLine,
+ ).gridArea.split(' / ');
+
+ return (
+ rowStart === '1' &&
+ columnStart === String(startDT.weekday) &&
+ rowEnd === '-1'
+ );
+ });
+ const changedDay =
+ targetGridLine && element.parentElement !== targetGridLine;
+
+ setTimeout(() => {
+ if (!calendar.contains(element)) return;
+
+
+ if (changedDay) {
+ targetGridLine.insertBefore(element, null);
+ element.classList.add('tempus-temporary-calendar-event');
+ }
+ element.style.gridRowEnd = 't_' + newPotentialEnd;
+ element.style.gridRowStart = 't_' + newPotentialStart;
+ }, 100);
+
+ const outerDiv = document.createElement('div');
+ outerDiv.className = 'spinner-overlay';
+
+ const innerDiv = document.createElement('div');
+ innerDiv.className = 'spinner';
+
+ outerDiv.appendChild(innerDiv);
+
+ element.appendChild(outerDiv);
+
+ const eventRect = element.getBoundingClientRect();
+
+ const offset = 300;
+
+ const isInsideScrolledView =
+ element.offsetLeft < calendar.scrollLeft + calendar.clientWidth &&
+ element.offsetLeft + element.offsetWidth > calendar.scrollLeft &&
+ element.offsetTop < calendar.scrollTop + calendar.clientHeight &&
+ element.offsetTop + element.offsetHeight > calendar.scrollTop;
+
+ const rect = element.getBoundingClientRect();
+ if (!isInsideScrolledView) {
+ element.scrollIntoView({
+ behavior: 'smooth',
+ inline: 'center',
+ block: 'nearest',
+ });
+ }
+ },
+ clearTemporaryEvents() {
+ const calendar = this.$refs.calendar?.$el;
+ if (!calendar) return;
+
+ const tempEvents = calendar.querySelectorAll(
+ '.tempus-temporary-calendar-event',
+ );
+ tempEvents.forEach((event) => {
+ event.remove();
+ });
+ }
+ },
+ watch: {
+ '$route.query': {
+ immediate: true,
+ handler(query) {
+ this.urlRange = getRangeFromQuery(query, this.config.timezone);
+ const rangeStart = this.urlRange?.start.toISODate()
+ ?? luxon.DateTime.now().setZone(this.config.timezone).toISODate();
+
+ this.calendarDatesByMode = {
+ ...this.calendarDatesByMode,
+ range: rangeStart,
+ };
+ },
+ },
+ lecturers: {
+ deep: true,
+ handler() {
+ this.rebuildExtraBackgrounds();
+ },
+ },
+ rooms() {
+ this.$refs.calendar.resetEventLoader();
+ },
+ },
+ mounted() {
+ this.reservierungPending = false;
+ this.bcc = new BroadcastChannel('fhc-dnd');
+ this.bcc.addEventListener('message', (e) => {
+ if (e.data === 'dropped' && !this.reservierungPending)
+ this.$refs.calendar.resetEventLoader();
+ });
+ },
+ beforeUnmount() {
+ this.bcc?.close();
+ },
+ async created() {
+ await this.$api
+ .call(ApiRenderers.loadTempusRenderers())
+ .then((res) => res.data)
+ .then((data) => {
+ console.log(Object.keys(data))
+ for (let rendertype of Object.keys(data)) {
+ let modalTitle = null;
+ let modalContent = null;
+ let calendarEvent = null;
+ if (data[rendertype].modalTitle)
+ modalTitle = Vue.markRaw(
+ Vue.defineAsyncComponent(
+ () => import(data[rendertype].modalTitle),
+ ),
+ );
+ if (data[rendertype].modalContent)
+ modalContent = Vue.markRaw(
+ Vue.defineAsyncComponent(
+ () => import(data[rendertype].modalContent),
+ ),
+ );
+ if (data[rendertype].calendarEvent)
+ calendarEvent = Vue.markRaw(
+ Vue.defineAsyncComponent(
+ () => import(data[rendertype].calendarEvent),
+ ),
+ );
+
+ if (data[rendertype].calendarEventStyles) {
+ var head = document.head;
+ if (
+ !head.querySelector(
+ `link[href="${data[rendertype].calendarEventStyles}"]`,
+ )
+ ) {
+ var link = document.createElement('link');
+ link.type = 'text/css';
+ link.rel = 'stylesheet';
+ link.href = data[rendertype].calendarEventStyles;
+ head.appendChild(link);
+ }
+ }
+
+ if (!this.renderers[rendertype]) {
+ this.renderers[rendertype] = {};
+ }
+ this.renderers[rendertype].modalTitle = modalTitle;
+ this.renderers[rendertype].modalContent = modalContent;
+ this.renderers[rendertype].calendarEvent = calendarEvent;
+ }
+ });
+ },
+ template: /* html */ `
+
+
+
`,
+};
diff --git a/public/js/composables/Tempus/TempusEventLoader.js b/public/js/composables/Tempus/TempusEventLoader.js
index 79fca1cb0..6d33e70fc 100644
--- a/public/js/composables/Tempus/TempusEventLoader.js
+++ b/public/js/composables/Tempus/TempusEventLoader.js
@@ -1,7 +1,10 @@
+const PLAN_REQUEST_INTERVAL_DAYS = 60;
+
export function useEventLoader(
rangeInterval,
getPromiseFunc,
cacheMultiplier = 1,
+ requestIntervalDays = PLAN_REQUEST_INTERVAL_DAYS,
) {
let hasFirstLoadOccurred = false;
@@ -24,6 +27,14 @@ export function useEventLoader(
return Math.round(currentlyDisplayedDateRange.length() * cacheSize) + 1;
};
+ const getRequestIntervalDays = () => {
+ const intervalDays = Number(Vue.toValue(requestIntervalDays));
+
+ return Number.isFinite(intervalDays) && intervalDays > 0
+ ? Math.max(1, Math.floor(intervalDays))
+ : PLAN_REQUEST_INTERVAL_DAYS;
+ };
+
const reload = (isCacheEnabled = true) => {
if (
currentlyDisplayedDateRange &&
@@ -74,56 +85,66 @@ export function useEventLoader(
addVisualForEventsLoading();
- Promise.allSettled(promises).then((results) => {
- let newlyLoadedEvents = [];
- let hasSuccessfullyLoadedEvents = false;
+ let newlyLoadedEvents = [];
+ let pendingPromiseCount = promises.length;
+ let hasSuccessfullyLoadedEvents = false;
- results.forEach((res) => {
- if (
- !(res.status === "fulfilled" && res.value.meta.status === "success")
- )
- return;
+ promises.forEach((promise) => {
+ Promise.resolve(promise)
+ .then((res) => {
+ if (res.meta.status !== "success") return;
- hasSuccessfullyLoadedEvents = true;
- if (res.value.meta.lv) lv.value = res.value.meta.lv;
- newlyLoadedEvents = res.value.data;
- });
+ hasSuccessfullyLoadedEvents = true;
+ if (res.meta.lv) lv.value = res.meta.lv;
+ newlyLoadedEvents = newlyLoadedEvents.concat(res.data);
- let tempAllEvents = Array.from(
- new Map(
- [...allEvents.value, ...newlyLoadedEvents].map((event) => [
- event.eindeutige_kalender_gruppen_id,
- event,
- ]),
- ).values(),
- );
-
- if (!isCacheEnabled && hasSuccessfullyLoadedEvents) {
- const newlyLoadedEventIds = new Set(
- newlyLoadedEvents.map(
- (event) => event.eindeutige_kalender_gruppen_id,
- ),
- );
-
- tempAllEvents = tempAllEvents.filter((event) => {
- const eventStart = getTimestampFromISODate(event.isostart);
- const eventEnd = getTimestampFromISODate(event.isoend);
- const overlapsDisplayedDateRange =
- eventStart < currentlyDisplayedDateRange.end.ts &&
- eventEnd > currentlyDisplayedDateRange.start.ts;
-
- return (
- !overlapsDisplayedDateRange ||
- newlyLoadedEventIds.has(event.eindeutige_kalender_gruppen_id)
+ let tempAllEvents = Array.from(
+ new Map(
+ [...allEvents.value, ...newlyLoadedEvents].map((event) => [
+ event.eindeutige_kalender_gruppen_id,
+ event,
+ ]),
+ ).values(),
);
+
+ if (!isCacheEnabled) {
+ const newlyLoadedEventIds = new Set(
+ newlyLoadedEvents.map(
+ (event) => event.eindeutige_kalender_gruppen_id,
+ ),
+ );
+
+ tempAllEvents = tempAllEvents.filter((event) => {
+ const eventStart = getTimestampFromISODate(event.isostart);
+ const eventEnd = getTimestampFromISODate(event.isoend);
+ const overlapsDisplayedDateRange =
+ eventStart < currentlyDisplayedDateRange.end.ts &&
+ eventEnd > currentlyDisplayedDateRange.start.ts;
+
+ return (
+ !overlapsDisplayedDateRange ||
+ newlyLoadedEventIds.has(event.eindeutige_kalender_gruppen_id)
+ );
+ });
+ }
+
+ allEvents.value = removeVisualForEventsLoading(
+ ensureEventsAreInValidCacheRange(tempAllEvents),
+ );
+ })
+ .catch(() => {})
+ .finally(() => {
+ pendingPromiseCount -= 1;
+
+ if (pendingPromiseCount === 0) {
+ if (!hasSuccessfullyLoadedEvents) {
+ allEvents.value = removeVisualForEventsLoading(
+ ensureEventsAreInValidCacheRange(allEvents.value),
+ );
+ }
+ hasFirstLoadOccurred = true;
+ }
});
- }
-
- allEvents.value = removeVisualForEventsLoading(
- ensureEventsAreInValidCacheRange(tempAllEvents),
- );
-
- hasFirstLoadOccurred = true;
});
};
@@ -188,6 +209,32 @@ export function useEventLoader(
allowedCacheStartTimestamp = startTimestamp - cachePadding;
allowedCacheEndTimestamp = endTimestamp + cachePadding;
+ const requestEnd = getLuxonDateFromMillis(modifiedRequestEndTimestamp);
+ let requestStart = getLuxonDateFromMillis(modifiedRequestStartTimestamp);
+ const intervalDays = getRequestIntervalDays();
+ const intervalEnd = requestStart
+ .plus({ days: intervalDays - 1 })
+ .endOf("day");
+
+ if (requestEnd > intervalEnd) {
+ while (requestStart < requestEnd) {
+ console.log("requestStart", requestStart.toISO() + " requestEnd", requestEnd.toISO() + " intervalDays", intervalDays);
+ let intervalRequestEnd = requestStart
+ .plus({ days: intervalDays - 1 })
+ .endOf("day");
+ if (intervalRequestEnd > requestEnd) intervalRequestEnd = requestEnd;
+
+ result = mergePromiseElements(
+ getPromiseFunc(requestStart, intervalRequestEnd),
+ result,
+ );
+
+ requestStart = intervalRequestEnd.plus({ milliseconds: 1 });
+ }
+
+ return result;
+ }
+
return mergePromiseElements(
getPromiseFunc(
getLuxonDateFromMillis(modifiedRequestStartTimestamp),