/* * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { arrays, CalendarComponent, CalendarDirection, CalendarDisplayMode, CalendarEventMap, CalendarLayout, CalendarListComponent, CalendarModel, CalendarModesMenu, CalendarMoveData, CalendarResourceDo, CalendarSidebar, ContextMenuPopup, DateRange, dates, Device, EventHandler, events, GroupBox, HtmlComponent, InitModelOf, JsonDateRange, KeyStrokeContext, Menu, menus, objects, Point, PropertyChangeEvent, ResourcePanel, scout, scrollbars, strings, UuidPool, ViewportScroller, Widget, YearPanel, YearPanelDateSelectEvent } from '../index'; import $ from 'jquery'; export class Calendar extends Widget implements CalendarModel { declare model: CalendarModel; declare eventMap: CalendarEventMap; declare self: Calendar; monthViewNumberOfWeeks: number; numberOfHourDivisions: number; widthPerDivision: number; heightPerDivision: number; startHour: number; heightPerHour: number; heightPerDay: number; spaceBeforeScrollTop: number; workDayIndices: number[]; displayCondensed: boolean; displayMode: CalendarDisplayMode; components: CalendarComponent[]; selectedComponent: CalendarComponent; loadInProgress: boolean; selectedDate: Date; selectorStart: Date; selectorEnd: Date; showDisplayModeSelection: boolean; rangeSelectionAllowed: boolean; resources: CalendarResourceDo[]; selectedResource: CalendarResourceDo; defaultResource: CalendarResourceDo; title: string; useOverflowCells: boolean; viewRange: DateRange; calendarToggleListWidth: number; calendarToggleYearWidth: number; menuInjectionTarget: GroupBox; modesMenu: CalendarModesMenu; menus: Menu[]; calendarSidebar: CalendarSidebar; yearPanel: YearPanel; resourcePanel: ResourcePanel; selectedRange: DateRange; needsScrollToStartHour: boolean; defaultMenuTypes: string[]; showCalendarSidebar: boolean; showResourcePanel: boolean; showListPanel: boolean; $header: JQuery; $range: JQuery; $commands: JQuery; $grids: JQuery; $grid: JQuery; $topGrid: JQuery; $list: JQuery; $listContainer: JQuery; $listTitle: JQuery; $progress: JQuery; $headerRow1: JQuery; $headerRow2: JQuery; $title: JQuery; $select: JQuery; $window: JQuery; /** * The narrow view range is different from the regular view range. * It contains only dates that exactly match the requested dates, * the regular view range contains also dates from the first and * next month. The exact range is not sent to the server. */ protected _exactRange: DateRange; /** * When the list panel is shown, this list contains the scout.CalenderListComponent * items visible on the list. */ protected _listComponents: CalendarListComponent[]; protected _menuInjectionTargetMenusChangedHandler: EventHandler>; /** * Resources, which do not have a child resources */ protected _leafResources: CalendarResourceDo[]; /** * Temporary data structure to store data while mouse actions are handled * @internal */ _moveData: CalendarMoveData; /** @internal */ _rangeSelectionStarted: boolean; protected _mouseMoveHandler: (event: JQuery.MouseMoveEvent) => void; protected _mouseUpHandler: (event: JQuery.MouseUpEvent) => void; protected _mouseMoveRangeSelectionHandler: (event: JQuery.MouseMoveEvent) => void; protected _mouseUpRangeSelectionHandler: (event: JQuery.MouseUpEvent) => void; constructor() { super(); this.monthViewNumberOfWeeks = 6; this.numberOfHourDivisions = 2; this.heightPerDivision = 30; this.startHour = 6; this.heightPerHour = this.numberOfHourDivisions * this.heightPerDivision; this.heightPerDay = 24 * this.heightPerHour; this.spaceBeforeScrollTop = 15; this.workDayIndices = [1, 2, 3, 4, 5]; // Workdays: Mon-Fri (Week starts at Sun in JS) this.components = []; this.displayCondensed = false; this.displayMode = Calendar.DisplayMode.MONTH; this.loadInProgress = false; this.selectedDate = new Date(); this.showDisplayModeSelection = true; this.rangeSelectionAllowed = false; this.defaultResource = this._createDefaultResource(); this.resources = []; this.selectedResource = this.defaultResource; this.title = null; this.useOverflowCells = true; this.viewRange = null; this.calendarToggleListWidth = 270; this.calendarToggleYearWidth = 215; this.defaultMenuTypes = [Calendar.MenuType.EmptySpace]; // main elements this.$container = null; this.$header = null; this.$range = null; this.$commands = null; this.$grids = null; this.$grid = null; this.$topGrid = null; this.$list = null; this.$progress = null; this.showCalendarSidebar = false; this.showListPanel = false; this.showResourcePanel = false; this._exactRange = null; this._listComponents = []; this.menuInjectionTarget = null; this._menuInjectionTargetMenusChangedHandler = null; this._moveData = null; this._mouseMoveHandler = this._onMouseMove.bind(this); this._mouseUpHandler = this._onMouseUp.bind(this); this._mouseMoveRangeSelectionHandler = this._onMouseMoveRangeSelection.bind(this); this._mouseUpRangeSelectionHandler = this._onMouseUpRangeSelection.bind(this); this.selectedRange = null; this._addWidgetProperties(['components', 'menus', 'selectedComponent', 'calendarSidebar']); this._addPreserveOnPropertyChangeProperties(['selectedComponent']); } /** * Enum providing display-modes for calender-like components like calendar and planner. * @see ICalendarDisplayMode.java */ static DisplayMode = { DAY: 1, WEEK: 2, MONTH: 3, WORK_WEEK: 4 } as const; /** * Used as a multiplier in date calculations back- and forward (in time). */ static Direction = { BACKWARD: -1, FORWARD: 1 } as const; static MenuType = { EmptySpace: 'Calendar.EmptySpace', CalendarComponent: 'Calendar.CalendarComponent' } as const; isDay(): boolean { return this.displayMode === Calendar.DisplayMode.DAY; } isWeek(): boolean { return this.displayMode === Calendar.DisplayMode.WEEK; } isMonth(): boolean { return this.displayMode === Calendar.DisplayMode.MONTH; } isWorkWeek(): boolean { return this.displayMode === Calendar.DisplayMode.WORK_WEEK; } protected override _createKeyStrokeContext(): KeyStrokeContext { return new KeyStrokeContext(); } protected _createDefaultResource(): CalendarResourceDo { return { resourceId: UuidPool.ZERO_UUID, selectable: true, visible: true }; } protected override _init(model: InitModelOf) { super._init(model); this.calendarSidebar = scout.create(CalendarSidebar, { parent: this }); this.yearPanel = this.calendarSidebar.yearPanel; this.resourcePanel = this.calendarSidebar.resoucePanel; this.resourcePanel.treeBox.on('propertyChange:value', this._onResourceVisibilityChanged.bind(this)); this.yearPanel.on('dateSelect', this._onYearPanelDateSelect.bind(this)); this.modesMenu = scout.create(CalendarModesMenu, { parent: this, visible: false, displayMode: this.displayMode }); this._setSelectedDate(this.selectedDate); this._setDisplayMode(this.displayMode); this._setMenuInjectionTarget(this.menuInjectionTarget); this._setResources(this.resources); this._exactRange = this._calcExactRange(); this.yearPanel.setViewRange(this._exactRange); this.viewRange = this._calcViewRange(); } protected _updateResourcePanelDisplayable() { this.calendarSidebar.setResourcePanelDisplayable(this.resources.length > 1); } setSelectedDate(date: Date | string) { this.setProperty('selectedDate', date); } protected _setSelectedDate(date: Date | string) { date = dates.ensure(date); scout.assertParameter('selectedDate', date, Date); this._setProperty('selectedDate', date); this.yearPanel.selectDate(this.selectedDate); } protected _renderSelectedDate() { this._updateModel(false); } setDisplayMode(displayMode: CalendarDisplayMode) { if (objects.equals(this.displayMode, displayMode)) { return; } let oldDisplayMode = this.displayMode; this._setDisplayMode(displayMode); if (this.rendered) { this._renderDisplayMode(oldDisplayMode); } } protected _setDisplayMode(displayMode: CalendarDisplayMode) { this._setProperty('displayMode', displayMode); this.yearPanel.setDisplayMode(this.displayMode); this.modesMenu.setDisplayMode(displayMode); if (this.isWorkWeek()) { // change date if selectedDate is on a weekend let p = this._dateParts(this.selectedDate, true); if (p.day > 4) { this.setSelectedDate(new Date(p.year, p.month, p.date - p.day + 4)); } } this.selectedRange = null; this.selectedResource = this.defaultResource; this.trigger('selectedRangeChange'); this.trigger('selectedResourceChange', {resourceId: null}); } protected _renderDisplayMode(oldDisplayMode?: CalendarDisplayMode) { if (this.rendering) { // only do it on property changes return; } this._updateModel(true); // only render if components have another layout let renderRequired = arrays.containsAny([this.displayMode, oldDisplayMode], [Calendar.DisplayMode.DAY, Calendar.DisplayMode.MONTH]); if (renderRequired) { this._renderComponents(); this.needsScrollToStartHour = true; } } protected _setViewRange(viewRange: DateRange | JsonDateRange) { viewRange = DateRange.ensure(viewRange); this._setProperty('viewRange', viewRange); } protected _setRangeSelectionAllowed(rangeSelectionAllowed: boolean) { this.rangeSelectionAllowed = rangeSelectionAllowed; if (!this.rangeSelectionAllowed) { this.setSelectedRange(null); } } setSelectedRange(range: DateRange | JsonDateRange) { let selectedRange = DateRange.ensure(range); if (selectedRange && selectedRange.from && selectedRange.to) { this.selectorStart = new Date(selectedRange.from); this.selectorStart.setHours(0, this._getHours(this.selectorStart) * 60); this.selectorEnd = new Date(selectedRange.to); this.selectorEnd.setHours(0, this._getHours(this.selectorEnd) * 60 - 30); this._setRangeSelection(); } else { this.selectorStart = null; this.selectorEnd = null; this._removeRangeSelection(); } this._updateSelectedRange(); } protected _setMenus(menus: Menu[]) { if (this._checkMenuInjectionTarget(this.menuInjectionTarget)) { let originalMenus = this._removeInjectedMenus(this.menuInjectionTarget, this.menus); this.menuInjectionTarget.setMenus(menus.concat(originalMenus)); } this._setProperty('menus', menus); } protected _setMenuInjectionTarget(menuInjectionTarget: GroupBox | string) { if (objects.isString(menuInjectionTarget)) { menuInjectionTarget = scout.widget(menuInjectionTarget) as GroupBox; } // Remove injected menus and installed listener from old injection target if (this._checkMenuInjectionTarget(this.menuInjectionTarget)) { menuInjectionTarget.off('propertyChange:menus', this._menuInjectionTargetMenusChangedHandler); let originalMenus = this._removeInjectedMenus(this.menuInjectionTarget, this.menus); this.menuInjectionTarget.setMenus(originalMenus); } if (this._checkMenuInjectionTarget(menuInjectionTarget)) { menuInjectionTarget.setMenus(this.menus.concat(menuInjectionTarget.menus)); // Listen for menu changes on the injection target. Re inject menus into target if the menus have been altered. this._menuInjectionTargetMenusChangedHandler = evt => { if (this.menuInjectionTarget.menus.some(element => this.menus.includes(element))) { // Menus have already been injected => Do nothing return; } this.menuInjectionTarget.setMenus(this.menus.concat(this.menuInjectionTarget.menus)); }; menuInjectionTarget.on('propertyChange:menus', this._menuInjectionTargetMenusChangedHandler); } this._setProperty('menuInjectionTarget', menuInjectionTarget); } protected _checkMenuInjectionTarget(menuInjectionTarget: GroupBox): boolean { return menuInjectionTarget instanceof GroupBox; } protected _removeInjectedMenus(menuInjectionTarget: GroupBox, injectedMenus: Menu[]): Menu[] { return menuInjectionTarget.menus.filter(element => !injectedMenus.includes(element)); } setShowCalendarSidebar(showCalendarSidebar: boolean) { this.setProperty('showCalendarSidebar', showCalendarSidebar); } setShowResourcePanel(showResourcePanel: boolean) { this.setProperty('showResourcePanel', showResourcePanel); } setShowListPanel(showListPanel: boolean) { this.setProperty('showListPanel', showListPanel); } protected override _render() { this.$container = this.$parent.appendDiv('calendar'); let layout = new CalendarLayout(this); this.htmlComp = HtmlComponent.install(this.$container, this.session); this.htmlComp.setLayout(layout); let isMobile = Device.get().type === Device.Type.MOBILE; // main elements this.$header = this.$container.appendDiv('calendar-header'); this.$header.toggleClass('mobile', isMobile); this.$headerRow1 = this.$header.appendDiv('calendar-header-row first'); this.$headerRow2 = this.$header.appendDiv('calendar-header-row last'); this.calendarSidebar.render(); this.$grids = this.$container.appendDiv('calendar-grids'); this.$topGrid = this.$grids.appendDiv('calendar-top-grid'); this.$topGrid.toggleClass('mobile', isMobile); this.$grid = this.$grids.appendDiv('calendar-grid'); this.$grid.toggleClass('mobile', isMobile); this.$listContainer = this.$container.appendDiv('calendar-list-container'); this.$list = this.$listContainer.appendDiv('calendar-list calendar-scrollable-components'); this.$listTitle = this.$list.appendDiv('calendar-list-title'); // header contains range, title and commands. On small screens title will be moved to headerRow2 this.$range = this.$headerRow1.appendDiv('calendar-range'); this.$range.appendDiv('calendar-previous') .on('click', this._onPreviousClick.bind(this)); this.$range.appendDiv('calendar-today', this.session.text('ui.CalendarToday')) .on('click', this._onTodayClick.bind(this)); this.$range.appendDiv('calendar-next') .on('click', this._onNextClick.bind(this)); // title this.$title = this.$headerRow1.appendDiv('calendar-title'); this.$select = this.$title.appendDiv('calendar-select'); this.$progress = this.$title.appendDiv('busyindicator-label'); // commands this.$commands = this.$headerRow1.appendDiv('calendar-commands'); this.$commands.appendDiv('calendar-mode first', this.session.text('ui.CalendarDay')) .attr('data-mode', Calendar.DisplayMode.DAY) .on('click', this._onDisplayModeClick.bind(this)); this.$commands.appendDiv('calendar-mode', this.session.text('ui.CalendarWorkWeek')) .attr('data-mode', Calendar.DisplayMode.WORK_WEEK) .on('click', this._onDisplayModeClick.bind(this)); this.$commands.appendDiv('calendar-mode', this.session.text('ui.CalendarWeek')) .attr('data-mode', Calendar.DisplayMode.WEEK) .on('click', this._onDisplayModeClick.bind(this)); this.$commands.appendDiv('calendar-mode last', this.session.text('ui.CalendarMonth')) .attr('data-mode', Calendar.DisplayMode.MONTH) .on('click', this._onDisplayModeClick.bind(this)); this.modesMenu.render(this.$commands); // Top-right menus this.$commands.appendDiv('calendar-toggle-sidebar') .on('click', this._onCalendarSidebarClick.bind(this)); this._updateResourcePanelDisplayable(); this.$commands.appendDiv('calendar-toggle-list') .on('click', this._onListClick.bind(this)); // Append the top grid (day/week views) let $weekHeader = this.$topGrid.appendDiv('calendar-week-header'); $weekHeader.appendDiv('calendar-week-name'); for (let dayTop = 0; dayTop < 7; dayTop++) { $weekHeader.appendDiv('calendar-day-name') .data('day', dayTop); } let $weekTopGridDays = this.$topGrid.appendDiv('calendar-week-allday-container'); $weekTopGridDays.appendDiv('calendar-week-name'); let dayContextMenuCallback = this._onDayContextMenu.bind(this); for (let dayBottom = 0; dayBottom < 7; dayBottom++) { $weekTopGridDays.appendDiv('calendar-day') .data('day', dayBottom) .on('contextmenu', dayContextMenuCallback); } for (let w = 1; w < 7; w++) { let $w = this.$grid.appendDiv('calendar-week'); for (let d = 0; d < 8; d++) { let $d = $w.appendDiv(); if (w > 0 && d === 0) { $d.addClass('calendar-week-name'); } else if (w > 0 && d > 0) { $d.addClass('calendar-day') .data('day', d) .data('week', w) .on('contextmenu', dayContextMenuCallback); } } } this.$window = this.$container.window(); this.$container.on('mousedown touchstart', this._onMouseDown.bind(this)); this._updateScreen(false, false); } protected override _renderProperties() { super._renderProperties(); this._renderResources(); this._renderComponents(); this._renderSelectedComponent(); this._renderLoadInProgress(); this._renderDisplayMode(); } setResources(resources: CalendarResourceDo[]) { this.setProperty('resources', resources); } protected _setResources(resources: CalendarResourceDo[]) { this._setProperty('resources', resources); this._updateLeafResources(); this._updateResourcePanelDisplayable(); this._updateResourcePanel(); this._validateSelectedResource(); } protected _renderResources() { let $dayName = this.$topGrid.find('.calendar-week-header > .calendar-day-name'); let $fullDay = this.$topGrid.find('.calendar-week-allday-container > .calendar-day'); let $day = this.$grid.find('.calendar-week > .calendar-day'); // Remove old resource columns $dayName.find('.resource-column').remove(); $fullDay.find('.resource-column').remove(); $day.find('.resource-column').remove(); let resourceColumnMouseOverCallback = this._onResourceColumnMouseOver.bind(this); // Add default resource columns $dayName.appendDiv('resource-column default') .data('resourceId', this.defaultResource.resourceId); $fullDay.appendDiv('resource-column default') .data('resourceId', this.defaultResource.resourceId) .addClass('calendar-scrollable-components') .on('mouseover', resourceColumnMouseOverCallback); $day.appendDiv('resource-column default') .data('resourceId', this.defaultResource.resourceId) .on('mouseover', resourceColumnMouseOverCallback); // Add new resources columns this._leafResources.forEach(resource => { $dayName.appendDiv('resource-column') .data('resourceId', resource.resourceId) .attr('data-resource-name', resource.name); let $fullDayColumn = $fullDay.appendDiv('resource-column') .data('resourceId', resource.resourceId) .addClass('calendar-scrollable-components'); let $dayColumn = $day.appendDiv('resource-column') .data('resourceId', resource.resourceId); if (resource.selectable) { // Only add event listener to selectable columns. This prevents components to be moved to // a resource that is not selectable $fullDayColumn.on('mouseover', resourceColumnMouseOverCallback); $dayColumn.on('mouseover', resourceColumnMouseOverCallback); } }); // click event on all day and children elements let mousedownCallbackWithTime = this._onDayColumnMouseDown.bind(this, true); this.$grid.find('.resource-column').on('mousedown', mousedownCallbackWithTime); let mousedownCallback = this._onDayColumnMouseDown.bind(this, false); this.$topGrid.find('.resource-column').on('mousedown', mousedownCallback); // Only required when rendering is triggered by setter if (!this.rendering) { this._renderComponents(); } } protected _updateResourcePanel() { this.resourcePanel.treeBox.lookupCall.setResources(this.resources); this.resourcePanel.treeBox.refreshLookup(); let value = this.resources .filter(resource => resource.visible) .map(resource => resource.resourceId); this.resourcePanel.treeBox.setValue(value); } protected _validateSelectedResource() { if (!arrays.contains(this.resources, this.selectedResource)) { this.selectedResource = this.defaultResource; this.trigger('selectedResourceChange', {resourceId: this.selectedResource.resourceId}); } } setComponents(components: CalendarComponent[]) { this.setProperty('components', components); } protected _setComponents(components: CalendarComponent[]) { let layoutRequired = false; if (this.isDay()) { // Re-layout required when visibility of default resource will change when setting new components layoutRequired = this._defaultResourceVisible() !== this._defaultResourceVisible(components); } this._setProperty('components', components); if (this.rendered && layoutRequired) { this.layoutSize(true); } } addComponents(components: CalendarComponent[]) { this.setComponents([...this.components, ...components]); } protected _renderComponents() { this.components.sort(this._sortFromTo); this._updateFullDayIndices(); this.components.forEach(component => component.remove()); this.components.forEach(component => component.render()); scrollbars.update(this.$grid); this._arrangeComponents(); this._updateListPanel(); } protected _renderSelectedComponent() { if (this.selectedComponent) { this.selectedComponent.setSelected(true); } } protected _renderLoadInProgress() { this.$progress.setVisible(this.loadInProgress); } updateScrollPosition(animate: boolean) { if (!this.rendered || !this.htmlComp?.layouted) { // Execute delayed because table may be not layouted yet this.session.layoutValidator.schedulePostValidateFunction(this._updateScrollPosition.bind(this, true, animate)); } else { this._updateScrollPosition(true, animate); } } protected _updateScrollPosition(scrollToInitialTime: boolean, animate: boolean) { if (this.isMonth()) { this._scrollToSelectedComponent(animate); } else { if (this.selectedComponent) { if (this.selectedComponent.fullDay) { this._scrollToSelectedComponent(animate); // scroll top-grid to selected component if (scrollToInitialTime) { this._scrollToInitialTime(animate); // scroll grid to initial time } } else { let date = dates.parseJsonDate(this.selectedComponent.fromDate); let topPercent = this._dayPosition(date.getHours(), date.getMinutes()) / 100; let topPos = this.heightPerDay * topPercent; scrollbars.scrollTop(this.$grid, topPos - this.spaceBeforeScrollTop, { animate: animate }); } } else if (scrollToInitialTime) { this._scrollToInitialTime(animate); } } } protected _updateScrollShadow() { scrollbars.updateScrollShadow(this.$grid); } protected _scrollToSelectedComponent(animate: boolean) { if (this.selectedComponent && this.selectedComponent._$parts[0] && this.selectedComponent._$parts[0].parent() && this.selectedComponent._$parts[0].isVisible()) { scrollbars.scrollTo(this.selectedComponent._$parts[0].parent(), this.selectedComponent._$parts[0], { animate: animate }); } } protected _scrollToInitialTime(animate: boolean) { this.needsScrollToStartHour = false; if (!this.isMonth()) { if (this.selectedComponent && !this.selectedComponent.fullDay) { let date = dates.parseJsonDate(this.selectedComponent.fromDate); let topPercent = this._dayPosition(date.getHours(), date.getMinutes()) / 100; let topPos = this.heightPerDay * topPercent; scrollbars.scrollTop(this.$grid, topPos - this.spaceBeforeScrollTop, { animate: animate }); } else { let scrollTargetTop = this.heightPerHour * this.startHour; scrollbars.scrollTop(this.$grid, scrollTargetTop - this.spaceBeforeScrollTop, { animate: animate }); } } } /* -- basics, events -------------------------------------------- */ protected _onPreviousClick() { this._navigateDate(Calendar.Direction.BACKWARD); } protected _onNextClick() { this._navigateDate(Calendar.Direction.FORWARD); } protected _dateParts(date: Date, modulo?: boolean): { year: number; month: number; date: number; day: number } { let parts = { year: date.getFullYear(), month: date.getMonth(), date: date.getDate(), day: date.getDay() }; if (modulo) { parts.day = (date.getDay() + 6) % 7; } return parts; } protected _navigateDate(direction: CalendarDirection) { this.selectedDate = this._calcSelectedDate(direction); this._updateModel(false); } protected _calcSelectedDate(direction: CalendarDirection): Date { // noinspection UnnecessaryLocalVariableJS let p = this._dateParts(this.selectedDate), dayOperand = direction, weekOperand = direction * 7, monthOperand = direction; if (this.isDay()) { return new Date(p.year, p.month, p.date + dayOperand); } if (this.isWeek() || this.isWorkWeek()) { return new Date(p.year, p.month, p.date + weekOperand); } if (this.isMonth()) { return dates.shift(this.selectedDate, 0, monthOperand, 0); } } protected _updateModel(animate: boolean) { this._exactRange = this._calcExactRange(); this.yearPanel.setViewRange(this._exactRange); this.viewRange = this._calcViewRange(); this.trigger('modelChange'); this._updateScreen(true, animate); } /** * Calculates exact date range of displayed components based on selected-date. */ protected _calcExactRange(): DateRange { let from, to, p = this._dateParts(this.selectedDate, true); if (this.isDay()) { from = new Date(p.year, p.month, p.date); to = new Date(p.year, p.month, p.date); } else if (this.isWeek()) { from = new Date(p.year, p.month, p.date - p.day); to = new Date(p.year, p.month, p.date - p.day + 6); } else if (this.isMonth()) { from = new Date(p.year, p.month, 1); to = new Date(p.year, p.month + 1, 0); } else if (this.isWorkWeek()) { from = new Date(p.year, p.month, p.date - p.day); to = new Date(p.year, p.month, p.date - p.day + 4); } else { throw new Error('invalid value for displayMode'); } return new DateRange(from, to); } /** * Calculates the view-range, which is what the user sees in the UI. * The view-range is wider than the exact-range in the monthly mode, * as it contains also dates from the previous and next month. */ protected _calcViewRange(): DateRange { let viewFrom = calcViewFromDate(this._exactRange.from), viewTo = calcViewToDate(viewFrom); return new DateRange(viewFrom, viewTo); function calcViewFromDate(fromDate: Date): Date { let i, tmpDate = new Date(fromDate.valueOf()); for (i = 0; i < 42; i++) { tmpDate.setDate(tmpDate.getDate() - 1); if ((tmpDate.getDay() === 1) && tmpDate.getMonth() !== fromDate.getMonth()) { return tmpDate; } } throw new Error('failed to calc viewFrom date'); } function calcViewToDate(fromDate: Date): Date { let i, tmpDate = new Date(fromDate.valueOf()); for (i = 0; i < 42; i++) { tmpDate.setDate(tmpDate.getDate() + 1); } return tmpDate; } } protected _onTodayClick(event: JQuery.ClickEvent) { this.selectedDate = new Date(); this._updateModel(false); } protected _onDisplayModeClick(event: JQuery.ClickEvent) { let displayMode = $(event.target).data('mode'); this.setDisplayMode(displayMode); } protected _onCalendarSidebarClick(event: JQuery.ClickEvent) { this.setShowCalendarSidebar(!this.showCalendarSidebar); this._updateScreen(true, true); } protected _onListClick(event: JQuery.ClickEvent) { this.setShowListPanel(!this.showListPanel); this._updateScreen(false, true); } protected _onDayColumnMouseDown(withTime: boolean, event: JQuery.MouseDownEvent) { let selectedDayColumn = $(event.delegateTarget), selectedDate = new Date(selectedDayColumn.parent().data('date')), timeChanged = false, selectedResourceId = selectedDayColumn.data('resourceId'), selectedResourceChanged = selectedResourceId !== this.selectedResource.resourceId; // Do not apply selection when clicked on calendar component if (this._get$CalendarComponent($(event.target)).length > 0) { return; } // When left click on component was made, which was covered by a range selection, apply the selection on the component let componentPartElement = document.elementsFromPoint(event.pageX, event.pageY).find(e => e.classList.contains('calendar-component')); if (event.button === 0 && componentPartElement) { let $part = $(componentPartElement as HTMLElement); let component = $part.data('component') as CalendarComponent; if (component) { component.applySelection($part); component.openPopup($part, event.originalEvent.clientY); return; } } // Check if date is valid if (!selectedDate.valueOf()) { return; } let rangeSelectionPossible = withTime && (this.isDay() || this.isWeek() || this.isWorkWeek()); // Set seconds of date if (rangeSelectionPossible) { let seconds = this._getSelectedSeconds(event); if (seconds < 60 * 60 * 24) { selectedDate.setSeconds(seconds); timeChanged = true; } } this._setSelection(selectedDate, selectedResourceId, null, false, timeChanged); if (rangeSelectionPossible) { this._startRangeSelection(event, selectedResourceChanged); } } protected _getSelectedDate(event: JQuery.MouseEventBase): Date { let date = null; let $target = $(event.target); if ($target.hasClass('calendar-day')) { date = $target.data('date'); } else if ($target.hasClass('resource-column')) { date = $target.parent().data('date'); } else if ($target.hasClass('calendar-component') || $target.parents('.calendar-component').length > 0 || $target.hasClass('calendar-range-selector')) { date = $target.closest('.calendar-day').data('date'); } if (date) { return new Date(date); } return null; } protected _getSelectedSeconds(event: JQuery.MouseEventBase): number { let y = event.originalEvent.layerY; let $target = $(event.target); let $component = this._get$CalendarComponent($target); if ($component.length > 0) { y += $component.position().top; } else if ($target.hasClass('calendar-range-selector')) { y += $target.position().top; } return Math.floor(y / this.heightPerDivision) / this.numberOfHourDivisions * 60 * 60; } protected _getSelectedDateTime(event: JQuery.MouseEventBase): Date { let selectedDate = this._getSelectedDate(event); if (selectedDate && (this.isDay() || this.isWeek() || this.isWorkWeek())) { let seconds = this._getSelectedSeconds(event); if (seconds < 60 * 60 * 24) { selectedDate.setSeconds(seconds); } } return selectedDate; } /** * @param selectedComponent may be null when a day is selected */ protected _setSelection(selectedDate: Date, selectedResource: CalendarResourceDo | string, selectedComponent: CalendarComponent, updateScrollPosition: boolean, timeChanged: boolean) { let changed = false; let dateChanged = dates.compareDays(this.selectedDate, selectedDate) !== 0; // selected date if (dateChanged || timeChanged) { changed = true; if (dateChanged) { $('.calendar-day', this.$container).each((index, element) => { let $day = $(element), date = $day.data('date'); if (!date || dates.compareDays(date, this.selectedDate) === 0) { $day.setSelected(false); // de-select old date } else if (dates.compareDays(date, selectedDate) === 0) { $day.setSelected(true); // select new date } }); } this.selectedDate = selectedDate; } // Set selected resource let newSelectedResource = this.selectedResource; if (objects.isString(selectedResource)) { newSelectedResource = this.findResourceForId(selectedResource as string); } else if (selectedResource) { newSelectedResource = selectedResource as CalendarResourceDo; } if (newSelectedResource !== this.selectedResource && newSelectedResource.selectable) { changed = true; this.selectedResource = newSelectedResource; this.trigger('selectedResourceChange', {resourceId: newSelectedResource.resourceId}); } // selected component / part (maybe null) if (this.selectedComponent !== selectedComponent) { changed = true; if (this.selectedComponent) { this.selectedComponent.setSelected(false); } if (selectedComponent) { selectedComponent.setSelected(true); } this.selectedComponent = selectedComponent; } if (changed) { this.trigger('selectionChange'); if (dateChanged) { this._updateListPanel(); } if (updateScrollPosition) { this._updateScrollPosition(false, true); } } if (this.showCalendarSidebar) { this.yearPanel.selectDate(this.selectedDate); } } protected _get$CalendarComponent($element: JQuery) { return $element.closest('.calendar-component'); } /* -- set display mode and range ------------------------------------- */ protected _updateScreen(updateTopGrid: boolean, animate: boolean) { $.log.isInfoEnabled() && $.log.info('(Calendar#_updateScreen)'); // select mode $('.calendar-mode', this.$commands).setSelected(false); $('[data-mode="' + this.displayMode + '"]', this.$commands).setSelected(true); // remove selected day $('.selected', this.$grid).setSelected(false); // layout grid this.layoutLabel(); this.layoutSize(animate); this.layoutAxis(); if (this.showCalendarSidebar) { this.yearPanel.selectDate(this.selectedDate); } this._updateListPanel(); this._updateScrollbars(this.$grid, animate); if (updateTopGrid && !this.isMonth()) { this._updateTopGrid(); } this.setSelectedRange(this.selectedRange); } layoutSize(animate?: boolean) { // reset animation sizes $('div', this.$container).removeData(['new-width', 'new-height']); this.$grids.toggleClass('calendar-grids-month', this.isMonth()); // init vars (Selected: Day) let $selected = $('.selected', this.$grid), $topSelected = $('.selected', this.$topGrid), containerW = this.$container.width(), gridH = this.$grid.height(), gridPaddingX = this.$grid.innerWidth() - this.$grid.width(), gridW = containerW - gridPaddingX; // show or hide calendar sidebar if (this.showCalendarSidebar) { this.calendarSidebar.$container.data('new-width', this.calendarToggleYearWidth); gridW -= this.calendarToggleYearWidth; containerW -= this.calendarToggleYearWidth; } else { this.calendarSidebar.$container.data('new-width', 0); } this.calendarSidebar.setResourcePanelExpanded(this.showResourcePanel); // show or hide work list $('.calendar-toggle-list', this.$commands).setSelected(this.showListPanel); if (this.showListPanel) { this.$list.parent().data('new-width', this.calendarToggleListWidth); gridW -= this.calendarToggleListWidth; containerW -= this.calendarToggleListWidth; } else { this.$list.parent().data('new-width', 0); } // basic grid width this.$grids.data('new-width', containerW); let $weeksToHide = $(); // Empty let $allWeeks = $('.calendar-week', this.$grid); // layout week if (this.isDay() || this.isWeek() || this.isWorkWeek()) { $allWeeks.removeClass('calendar-week-noborder'); // Parent of selected (Day) is a week let selectedWeek = $selected.parent(); $weeksToHide = $allWeeks.not(selectedWeek); // Hide all (other) weeks delayed, height will animate to zero $weeksToHide.data('new-height', 0); $weeksToHide.removeClass('invisible'); selectedWeek.data('new-height', this.heightPerDay); selectedWeek.addClass('calendar-week-noborder'); selectedWeek.removeClass('hidden invisible'); // Current week must be shown $('.calendar-day', selectedWeek).data('new-height', this.heightPerDay); // Hide the week-number in the lower grid $('.calendar-week-name', this.$grid).addClass('invisible'); // Keep the reserved space $('.calendar-week-allday-container', this.$topGrid).removeClass('hidden'); $('.calendar-week-task', this.$topGrid).removeClass('hidden'); } else { // Month let newHeightMonth = gridH / this.monthViewNumberOfWeeks; $allWeeks.removeClass('calendar-week-noborder invisible hidden'); $allWeeks.eq(0).addClass('calendar-week-noborder'); $allWeeks.data('new-height', newHeightMonth); $('.calendar-day', this.$grid).data('new-height', newHeightMonth); let $allDays = $('.calendar-week-name', this.$grid); $allDays.removeClass('hidden invisible'); $allDays.data('new-height', newHeightMonth); $('.calendar-week-allday-container', this.$topGrid).addClass('hidden'); $('.calendar-week-task', this.$topGrid).addClass('hidden'); } // layout days let contentW = gridW - 45; // gridW - @calendar-week-name-width if (this.isDay()) { $('.calendar-day-name, .calendar-day', this.$topGrid).data('new-width', 0); $('.calendar-day', this.$grid).data('new-width', 0); $('.calendar-day-name:nth-child(' + ($topSelected.index() + 1) + ')', this.$topGrid) .data('new-width', contentW); $('.calendar-day:nth-child(' + ($topSelected.index() + 1) + ')', this.$topGrid).data('new-width', contentW); $('.calendar-day:nth-child(' + ($selected.index() + 1) + ')', this.$grid).data('new-width', contentW); this.widthPerDivision = contentW; } else if (this.isWorkWeek()) { this.$topGrid.find('.calendar-day-name').data('new-width', 0); this.$grids.find('.calendar-day').data('new-width', 0); let newWidthWorkWeek = Math.round(contentW / this.workDayIndices.length); this.$topGrid.find('.calendar-day-name').slice(0, 5).data('new-width', newWidthWorkWeek); this.$topGrid.find('.calendar-day').slice(0, 5).data('new-width', newWidthWorkWeek); $('.calendar-day:nth-child(-n+6)', this.$grid).data('new-width', newWidthWorkWeek); this.widthPerDivision = newWidthWorkWeek; } else if (this.isMonth() || this.isWeek()) { let newWidthMonthOrWeek = Math.round(contentW / 7); this.$grids.find('.calendar-day').data('new-width', newWidthMonthOrWeek); this.$topGrid.find('.calendar-day-name').data('new-width', newWidthMonthOrWeek); this.widthPerDivision = newWidthMonthOrWeek; } // layout resource columns let columnWidth = 0; if (this.isDay()) { columnWidth = Math.round(contentW / (this._leafResources.filter(c => c.visible).length + (this._defaultResourceVisible() ? 1 : 0))); } else if (this.isWorkWeek()) { columnWidth = Math.round(contentW / this.workDayIndices.length); } else { columnWidth = Math.round(contentW / 7); } if (this.isDay()) { // Set size to 0 for all $('.resource-column', this.$grids).data('new-width', 0); // Resize visible columns of selected day $('.calendar-day-name:nth-child(' + ($topSelected.index() + 1) + ')', this.$topGrid) .add($('.calendar-day:nth-child(' + ($topSelected.index() + 1) + ')', this.$grids)) .find('.resource-column') .filter((i, e) => { let id = $(e).data('resourceId'); if (id === this.defaultResource.resourceId) { return this._defaultResourceVisible(); } let foundResource = this.findResourceForId(id); return foundResource ? foundResource.visible : false; }).data('new-width', columnWidth); } else { // Set size 0 for all resource columns $('.resource-column', this.$grids).data('new-width', 0); // Full size for default column $('.resource-column', this.$grids) .filter((i, e) => $(e).data('resourceId') === this.defaultResource.resourceId) .data('new-width', columnWidth); } // layout components if (this.isMonth()) { $('.component-month', this.$grid).each(function() { let $comp = $(this), $day = $comp.closest('.calendar-day'); $comp.toggleClass('compact', $day.data('new-width') < CalendarComponent.MONTH_COMPACT_THRESHOLD); }); } // animate old to new sizes $('div', this.$container).each((i, elem) => { let $e = $(elem); let w = $e.data('new-width'); let h = $e.data('new-height'); $e.stop(false, true); if (w !== undefined && w !== $e.outerWidth()) { if (animate) { let opts: JQuery.EffectsOptions = { complete: () => this._afterLayout($e, animate) }; if ($e[0] === this.$grids[0]) { // Grid contains scroll shadows that should be updated during animation (don't due it always for performance reasons) opts.progress = () => this._afterLayout($e, animate); } $e.animate({width: w}, opts); } else { $e.css('width', w); this._afterLayout($e, animate); } } if (h !== undefined && h !== $e.outerHeight()) { if (h > 0) { $e.removeClass('hidden'); } if (animate) { $e.animateAVCSD('height', h, () => { if (h === 0) { $e.addClass('hidden'); } this._afterLayout($e, animate); }, () => { this._afterLayout($e, animate); }); } else { $e.css('height', h); if (h === 0) { $e.addClass('hidden'); } this._afterLayout($e, animate); } } }); } protected _afterLayout($parent: JQuery, animate: boolean) { this.calendarSidebar.invalidateLayoutTree(false); this._updateScrollbars($parent, animate); this._updateWeekdayNames(); } protected _defaultResourceVisible(components = this.components): boolean { return components .filter(comp => comp.coveredDaysRange.covers(this.selectedDate, true)) .map(comp => comp.item) .map(item => item ? item.resourceId : this.defaultResource.resourceId) .map(resourceId => this.isLeafResource(resourceId) ? resourceId : this.defaultResource.resourceId) .filter(resourceId => resourceId === this.defaultResource.resourceId) .length > 0; } isLeafResource(resourceId: string) { return !!this._leafResources.find(res => res.resourceId === resourceId); } findResourceForComponent(component: CalendarComponent): CalendarResourceDo { return this.findResourceForId(component.getResourceId()); } findResourceForId(resourceId: string): CalendarResourceDo { if (!resourceId || resourceId === this.defaultResource.resourceId) { return this.defaultResource; } let resource = this.resources.find(resource => resource.resourceId === resourceId); // No resource found for this id if (!resource) { return this.defaultResource; } return resource; } protected _updateWeekdayNames() { // set day-name (based on width of shown column) let weekdayWidth = this.$topGrid.width(), weekdays; if (this.isDay()) { weekdayWidth /= 1; } else if (this.isWorkWeek()) { weekdayWidth /= this.workDayIndices.length; } else if (this.isWeek()) { weekdayWidth /= 7; } else if (this.isMonth()) { weekdayWidth /= 7; } if (weekdayWidth > 90) { weekdays = this.session.locale.dateFormat.symbols.weekdaysOrdered; } else { weekdays = this.session.locale.dateFormat.symbols.weekdaysShortOrdered; } $('.calendar-day-name > .resource-column', this.$topGrid) .filter((i, element) => $(element).data('resourceId') === this.defaultResource.resourceId) .each((i, elm) => { $(elm).attr('data-resource-name', weekdays[i]); }); } protected _updateScrollbars($parent: JQuery, animate: boolean) { let $scrollables = $('.calendar-scrollable-components', $parent); $scrollables.each((i, elem) => { scrollbars.update($(elem), true); }); this.updateScrollPosition(animate); this._updateScrollShadow(); } protected _uninstallComponentScrollbars($parent: JQuery) { $parent.find('.calendar-scrollable-components').each((i, elem) => { scrollbars.uninstall($(elem), this.session); }); } protected _updateTopGrid() { $('.calendar-component', this.$topGrid).each((index, part) => { let component = $(part).data('component'); if (component) { component.remove(); } }); const fullDayComponents = this.components.filter(component => component.fullDay); this._updateFullDayIndices(fullDayComponents); // first remove all components and add them from scratch fullDayComponents.forEach(component => component.remove()); fullDayComponents.forEach(component => component.render()); this._updateScrollbars(this.$topGrid, false); scrollbars.update(this.$grid); } protected _updateFullDayIndices(fullDayComponents?: CalendarComponent[]) { if (!fullDayComponents) { fullDayComponents = this.components.filter(component => component.fullDay); } fullDayComponents.sort(this._sortFromTo); const {from, to} = this._exactRange; const usedIndicesMap = new Map(); let maxComponentsPerDay = 0; for (const component of fullDayComponents) { component.fullDayIndex = -1; if (component.coveredDaysRange.to < from || component.coveredDaysRange.from > to) { // component is not in range continue; } let date = component.coveredDaysRange.from; if (date < from) { date = from; } let fullDayIndexKey = this._calculateFullDayIndexKey(component, date); let usedIndices = arrays.ensure(usedIndicesMap.get(fullDayIndexKey)); // get the first unused index // create [0, 1, 2, ..., maxIndex, maxIndex + 1] remove the used indices // => the minimum of the remaining array is the first unused index const maxIndex = usedIndices.length ? arrays.max(usedIndices) : 0; const indexCandidates = arrays.init(maxIndex + 2, null).map((elem, idx) => idx); arrays.removeAll(indexCandidates, usedIndices); const index = arrays.min(indexCandidates); component.fullDayIndex = index; // mark the index as used for all dates of the components range // none of these indices can be used already due to the order of the components while (date <= component.coveredDaysRange.to && date <= to) { usedIndices.push(index); usedIndicesMap.set(fullDayIndexKey, usedIndices); date = dates.shift(date, 0, 0, 1); fullDayIndexKey = this._calculateFullDayIndexKey(component, date); usedIndices = arrays.ensure(usedIndicesMap.get(fullDayIndexKey)); } maxComponentsPerDay = Math.max(index + 1, maxComponentsPerDay); } this.$grids.css('--full-day-components', maxComponentsPerDay); } protected _calculateFullDayIndexKey(component: CalendarComponent, date: Date): string { let prefix = ''; if (this.isDay()) { prefix = component.getResourceId(); } return prefix + date.valueOf(); } layoutYearPanel() { if (this.showCalendarSidebar) { scrollbars.update(this.yearPanel.$yearList); this.yearPanel._scrollYear(); } } layoutLabel() { let text, $dates, $topGridDates, exFrom = this._exactRange.from, exTo = this._exactRange.to; // set range text if (this.isDay()) { text = this._format(exFrom, 'EEEE, d. MMMM yyyy'); } else if (this.isWorkWeek() || this.isWeek()) { let toText = this.session.text('ui.to'); if (exFrom.getMonth() === exTo.getMonth()) { text = strings.join(' ', this._format(exFrom, 'd.'), toText, this._format(exTo, 'd. MMMM yyyy')); } else if (exFrom.getFullYear() === exTo.getFullYear()) { text = strings.join(' ', this._format(exFrom, 'd. MMMM'), toText, this._format(exTo, 'd. MMMM yyyy')); } else { text = strings.join(' ', this._format(exFrom, 'd. MMMM yyyy'), toText, this._format(exTo, 'd. MMMM yyyy')); } } else if (this.isMonth()) { text = this._format(exFrom, 'MMMM yyyy'); } this.$select.text(text); // prepare to set all day date and mark selected one $dates = $('.calendar-day', this.$grid); let w, d, cssClass, currentMonth = this._exactRange.from.getMonth(), date = new Date(this.viewRange.from.valueOf()); // Main grid: loop all days and set value and class for (w = 0; w < this.monthViewNumberOfWeeks; w++) { for (d = 0; d < 7; d++) { cssClass = ''; if (this.workDayIndices.indexOf(date.getDay()) === -1) { cssClass = date.getMonth() !== currentMonth ? ' weekend-out' : ' weekend'; } else { cssClass = date.getMonth() !== currentMonth ? ' out' : ''; } if (dates.isSameDay(date, new Date())) { cssClass += ' now'; } if (dates.isSameDay(date, this.selectedDate)) { cssClass += ' selected'; } if (!this.isMonth()) { cssClass += ' calendar-no-label'; // If we're not in the month view, number is shown on top } // adjust position for days between 10 and 19 (because "1" is narrower than "0" or "2") if (date.getDate() > 9 && date.getDate() < 20) { cssClass += ' center-nice'; } text = this._format(date, 'dd'); $dates.eq(w * 7 + d) .removeClass('weekend-out weekend out selected now calendar-no-label') .addClass(cssClass) .attr('data-day-name', text) .data('date', new Date(date.valueOf())); date.setDate(date.getDate() + 1); } } // Top grid: loop days of one calendar week and set value and class if (!this.isMonth()) { $topGridDates = $('.calendar-day', this.$topGrid); // From the view range, find the week we are in let exactDate = new Date(this._exactRange.from.valueOf()); // Find first day of week. date = dates.firstDayOfWeek(exactDate, 1); for (d = 0; d < 7; d++) { cssClass = ''; if (this.workDayIndices.indexOf(date.getDay()) === -1) { cssClass = date.getMonth() !== currentMonth ? ' weekend-out' : ' weekend'; } else { cssClass = date.getMonth() !== currentMonth ? ' out' : ''; } if (dates.isSameDay(date, new Date())) { cssClass += ' now'; } if (dates.isSameDay(date, this.selectedDate)) { cssClass += ' selected'; } text = this._format(date, 'dd'); $topGridDates.eq(d) .removeClass('weekend-out weekend out selected now') .addClass(cssClass) .attr('data-day-name', text) .data('date', new Date(date.valueOf())); date.setDate(date.getDate() + 1); } } } layoutAxis() { let $e; // remove old axis $('.calendar-week-axis, .calendar-week-task', this.$grid).remove(); // set week name let session = this.session; $('.calendar-week-name', this.$container).each(function(index) { if (index > 0) { $e = $(this); $e.text(session.text('ui.CW', dates.weekInYear($e.next().data('date')) + '')); } }); // day schedule if (!this.isMonth()) { // Parent of selected day: Week // var $parent = $selected.parent(); let $parent = $('.calendar-week', this.$grid); for (let h = 0; h < 24; h++) { // Render lines for each hour let paddedHour = ('00' + h).slice(-2); let topPos = h * this.heightPerHour; $parent.appendDiv('calendar-week-axis hour' + (h === 0 ? ' first' : '')).attr('data-axis-name', paddedHour + ':00').css('top', topPos + 'px'); for (let m = 1; m < this.numberOfHourDivisions; m++) { // First one rendered above. Start at the next topPos += this.heightPerDivision; $parent.appendDiv('calendar-week-axis').attr('data-axis-name', '').css('top', topPos + 'px'); } } } } /* -- year events ---------------------------------------- */ protected _onYearPanelDateSelect(event: YearPanelDateSelectEvent) { this.selectedDate = event.date; this._updateModel(false); } protected _updateListPanel() { if (this.showListPanel) { scrollbars.uninstall(this.$list, this.session); // remove old list-components this._listComponents.forEach(listComponent => listComponent.remove()); this._listComponents = []; this._renderListPanel(); scrollbars.install(this.$list, { parent: this, session: this.session, axis: 'y' }); } } protected override _remove() { this._uninstallComponentScrollbars(this.$grid); this._uninstallComponentScrollbars(this.$topGrid); scrollbars.uninstall(this.$list, this.session); this.$window .off('mousemove touchmove', this._mouseMoveHandler) .off('mouseup touchend touchcancel', this._mouseUpHandler); this._moveData = null; super._remove(); } /** * Renders the panel on the left, showing all components of the selected date. */ protected _renderListPanel() { // set title this.$listTitle.text(this._format(this.selectedDate, 'd. MMMM yyyy')); // filter components to display on the list panel this.components .filter(comp => this._filterCurrentDate(comp)) .filter(comp => this._filterVisibleComponents(comp)) .forEach(comp => { let listComponent = new CalendarListComponent(this.selectedDate, comp); listComponent.render(this.$list); this._listComponents.push(listComponent); }); } protected _filterCurrentDate(component: CalendarComponent): boolean { let selectedDate = dates.trunc(this.selectedDate); return dates.compare(selectedDate, component.coveredDaysRange.from) >= 0 && dates.compare(selectedDate, component.coveredDaysRange.to) <= 0; } protected _filterVisibleComponents(component: CalendarComponent) { return this.findResourceForComponent(component).visible; } /* -- components, events-------------------------------------------- */ /** @internal */ _selectedComponentChanged(component: CalendarComponent, resourceId: string, partDay: Date, updateScrollPosition: boolean) { this._setSelection(partDay, resourceId, component, updateScrollPosition, false); } protected _onDayContextMenu(event: JQuery.ContextMenuEvent) { this._showContextMenu(event, Calendar.MenuType.EmptySpace); } /** @internal */ _showContextMenu(event: JQuery.ContextMenuEvent, allowedType: string) { event.preventDefault(); event.stopPropagation(); let func = function func(event: JQuery.ContextMenuEvent, allowedType: string) { if (!this.rendered || !this.attached || !this._calculateContextMenuVisible(event)) { // check needed because function is called asynchronously return; } if (allowedType === Calendar.MenuType.CalendarComponent && !this.selectedComponent) { // When right click is started outside the component and released on the component, // no component is selected -> no context menu is opened return; } let filteredMenus = menus.filter(this.menus, [allowedType], { onlyVisible: true, defaultMenuTypes: this.defaultMenuTypes }), $part = $(event.currentTarget); if (filteredMenus.length === 0) { return; } let popup = scout.create(ContextMenuPopup, { parent: this, menuItems: filteredMenus, location: { x: event.pageX, y: event.pageY }, $anchor: $part }); popup.open(); }.bind(this); this.session.onRequestsDone(func, event, allowedType); } protected _calculateContextMenuVisible(event: JQuery.ContextMenuEvent): boolean { let $target = $(event.currentTarget); let resourceId = this.defaultResource.resourceId; if ($target.hasClass('calendar-component') && this.selectedComponent) { resourceId = this.selectedComponent.getResourceId(); } else { resourceId = $(event.target).closest('.resource-column').data('resourceId'); } return this.findResourceForId(resourceId).selectable; } protected _onResourceVisibilityChanged(event: PropertyChangeEvent) { let checkedNodes = event.newValue; let resourceCheckedTuples = this.resources .map(cal => cal.resourceId) .map(calId => [calId, checkedNodes.includes(calId)] as [string, boolean]); this._updateResourceVisibility(resourceCheckedTuples); } protected _updateResourceVisibility(updatedResources: [resourceId: string, visible: boolean][]) { if (!this.initialized) { // Is called after init return; } updatedResources.forEach(tuple => { this._updateResourcesVisibleProperty(tuple[0], tuple[1]); }); if (!this.rendered) { return; } if (!this.isDay()) { // No re-rendering required when on day -> component is hidden by layout this._renderComponents(); } else { // Else only update list panel this._updateListPanel(); } this.layoutSize(true); } protected _updateResourcesVisibleProperty(resourceId: string, visible: boolean) { if (!resourceId) { // No update return; } let resource = this.findResourceForId(resourceId); if (!resource || resource.visible === visible) { // No update return; } resource.visible = visible; this.trigger('resourceVisibilityChange', { resourceId: resourceId, visible: visible }); } /* -- components, arrangement------------------------------------ */ protected _arrangeComponents() { let k: number, c: number, j: number, $day: JQuery, $columns: JQuery, $defaultColumn: JQuery, $allChildren: JQuery, $children: JQuery, $scrollableContainer: JQuery, dayComponents: CalendarComponent[], day: Date; let $days = $('.calendar-day', this.$grid); // Main (Bottom) grid: Iterate over days for (k = 0; k < $days.length; k++) { $day = $days.eq(k); day = $day.data('date'); $columns = $day.children('.resource-column'); $allChildren = $day.find('.calendar-component'); $defaultColumn = $columns.filter((i, e) => $(e).data('resourceId') === this.defaultResource.resourceId); // Remove old element containers $scrollableContainer = $defaultColumn.children('.calendar-scrollable-components'); if ($scrollableContainer.length > 0) { scrollbars.uninstall($scrollableContainer, this.session); $scrollableContainer.remove(); } if (this.isMonth()) { $scrollableContainer = $defaultColumn.appendDiv('calendar-scrollable-components'); for (j = 0; j < $allChildren.length; j++) { let $child = $allChildren.eq(j); // non-tasks (communications) are distributed manually // within the parent container in all views except the monthly view. if (!this.isMonth() && !$child.hasClass('component-task')) { continue; } $scrollableContainer.append($child); } scrollbars.install($scrollableContainer, { parent: this, session: this.session, axis: 'y' }); } for (c = 0; c < $columns.length; c++) { $children = $columns.eq(c).children('.calendar-component:not(.component-task)'); if (this.isMonth() && $children.length > 2) { $day.addClass('many-items'); } else if (!this.isMonth() && $children.length > 1) { // logical placement dayComponents = this._getComponents($children); this._arrange(dayComponents, day); // screen placement this._arrangeComponentSetPlacement($children, day); } } } if (this.isMonth()) { this._uninstallScrollbars(); this._uninstallComponentScrollbars(this.$topGrid); this.$grid.removeClass('calendar-scrollable-components'); } else { this.$grid.addClass('calendar-scrollable-components'); // If we're in the non-month views, the time can scroll. Add scrollbars this._installScrollbars({ parent: this, session: this.session, axis: 'y' }); this.$topGrid.find('.calendar-scrollable-components') .each((i, elem) => { let $topDay = $(elem); if ($topDay.data('scrollable')) { scrollbars.update($topDay); return; } scrollbars.install($topDay, { parent: this, session: this.session, axis: 'y', scrollShadow: 'none' }); }); } } protected _getComponents($children: JQuery): CalendarComponent[] { let i, $child; let components = []; for (i = 0; i < $children.length; i++) { $child = $children.eq(i); components.push($child.data('component')); } return components; } /** @internal */ _sort(components: CalendarComponent[]) { components.sort(this._sortFromTo); } /** * Arrange components (stack width, stack index) per day */ protected _arrange(components: CalendarComponent[], day: Date) { let columns: CalendarComponent[] = [], key = day + ''; // ordered by from, to this._sort(components); // clear existing placement for (let i = 0; i < components.length; i++) { let c = components[i]; if (!c.stack) { c.stack = {}; } c.stack[this._calculateStackKey(day, c.getResourceId())] = {}; } for (let i = 0; i < components.length; i++) { let c = components[i]; let r = c.getPartDayPosition(day); // Range [from,to] key = this._calculateStackKey(day, c.getResourceId()); // reduce number of columns, if all components end before this one if (columns.length > 0 && this._allEndBefore(columns, r.from, day)) { columns = []; } // replace a component that ends before and can be replaced let k = this._findReplaceableColumn(columns, r.from, day); // insert if (k >= 0) { columns[k] = c; c.stack[key].x = k; } else { columns.push(c); c.stack[key].x = columns.length - 1; } // update stackW for (let j = 0; j < columns.length; j++) { columns[j].stack[key].w = columns.length; } } } protected _allEndBefore(columns: CalendarComponent[], pos: number, day: Date): boolean { let i; for (i = 0; i < columns.length; i++) { if (!this._endsBefore(columns[i], pos, day)) { return false; } } return true; } protected _findReplaceableColumn(columns: CalendarComponent[], pos: number, day: Date): number { let j; for (j = 0; j < columns.length; j++) { if (this._endsBefore(columns[j], pos, day)) { return j; } } return -1; } protected _endsBefore(component: CalendarComponent, pos: number, day: Date): boolean { return component.getPartDayPosition(day).to <= pos; } protected _arrangeComponentSetPlacement($children: JQuery, day: Date) { let i, $child, component: CalendarComponent, stack; // loop and place based on data for (i = 0; i < $children.length; i++) { $child = $children.eq(i); component = $child.data('component'); stack = component.stack[this._calculateStackKey(day, component.getResourceId())]; // make last element smaller $child .css('width', 100 / stack.w + '%') .css('left', stack.x * 100 / stack.w + '%'); } } protected _calculateStackKey(date: Date, resourceId?: string): string { let stackKey = this.defaultResource.resourceId; // Separate layouting of components by resourceId when on day if (this.isDay() && resourceId) { // Validate resourceId stackKey = this.findResourceForId(resourceId).resourceId; } return stackKey; } override get$Scrollable(): JQuery { return this.$grid; } protected _onMouseDown(event: JQuery.MouseDownEvent) { if (this._moveData) { // Do nothing, when dragging is already in progress. This can happen when the user leaves // the browser window (e.g. using Alt-Tab) while holding the mouse button pressed and // then returns and presses the mouse button again. return; } let component = this._getCalendarComponentForMouseEvent(event); // component has to be marked as draggable by the backend // dragging of fullDay components is not supported yet // dragging of components spanning more than one day is not supported yet if (!component || !component.draggable || (!this.isMonth() && component.fullDay) || component._$parts.length > 1) { return; } // Ignore right mouse button clicks (allow bubble up --> could trigger context menu) if (event.which === 3) { // Select clicked widget first, otherwise we might display the wrong context menu return; } this.$window .off('mousemove touchmove', this._mouseMoveHandler) .off('mouseup touchend touchcancel', this._mouseUpHandler) .on('mousemove touchmove', this._mouseMoveHandler) .on('mouseup touchend touchcancel', this._mouseUpHandler); this._moveData = this._newMoveData(event); this._onComponentMouseDown(event, component); this._moveData.onMove = this._onComponentMouseMove.bind(this); this._moveData.onUp = this._onComponentMouseUp.bind(this); } protected _onMouseMove(event: JQuery.MouseMoveEvent) { events.fixTouchEvent(event); this._moveData.event = event; this._moveData.currentCursorPosition = new Point( event.pageX - this._moveData.containerOffset.left + this._moveData.containerScrollPosition.x, event.pageY - this._moveData.containerOffset.top + this._moveData.containerScrollPosition.y ); this._captureVirtualPositionByCursor(event.pageY); this._scrollViewportWhileDragging(event); if (this._moveData.onMove && !this._moveData.cancelled) { this._moveData.onMove(event); } } protected _onResourceColumnMouseOver(event: JQuery.MouseEnterEvent) { if (this._moveData?.moving) { this._captureVirtualPositionByContainer($(event.currentTarget)); } } protected _scrollViewportWhileDragging(event: JQuery.MouseMoveEvent) { if (!this._moveData || this._moveData.cancelled) { return; } if (!this._moveData.viewportScroller) { this._moveData.viewportScroller = scout.create(ViewportScroller, $.extend({ viewportWidth: this.$grid.width(), viewportHeight: this.$grid.height(), active: () => !!this._moveData, scroll: (dx, dy) => { let newScrollTop = this.get$Scrollable().scrollTop() + dy; scrollbars.scrollTop(this.get$Scrollable(), newScrollTop); this._moveData.containerScrollPosition = new Point(this.get$Scrollable().scrollLeft(), this.get$Scrollable().scrollTop()); } })); } // Mouse position (viewport-relative coordinates) in pixel let mouse = this._moveData.currentCursorPosition.subtract(this._moveData.containerScrollPosition); this._moveData.viewportScroller.update(mouse); } protected _onMouseUp(event: JQuery.MouseUpEvent) { this.$window .off('mousemove touchmove', this._mouseMoveHandler) .off('mouseup touchend touchcancel', this._mouseUpHandler); events.fixTouchEvent(event); this._moveData.event = event; if (this._moveData.onUp && !this._moveData.cancelled) { this._moveData.onUp(event); } this._moveData = null; } protected _onComponentMouseDown(event: JQuery.MouseDownEvent, component: CalendarComponent) { // Prepare for dragging this._moveData.component = component; let $firstPart = component._$parts[0]; this._moveData.$movePart?.remove(); this._moveData.$movePart = $firstPart.clone() .addClass('dragged') // reduce opacity .width('').cssLeft(''); // remove style attribute -> full width this._captureVirtualPositionByContainer($firstPart.closest('.resource-column')); this._captureVirtualPositionByCursor(event.pageY); if (!this.isMonth()) { this._moveData.virtualOffset = (this._calculateDateForComponentPart($firstPart).getTime() - dates.parseJsonDate(this._moveData.component.fromDate).getTime()) / (60 * 1000) // offset in minutes + this._moveData.virtualY; // Add offset from top } // Prevent scrolling on touch devices (like "touch-action: none" but with better browser support). // Theoretically, unwanted scrolling can be prevented by adding the CSS rule "touch-action: none" // to the element. Unfortunately, not all devices support this (e.g. Apple Safari on iOS). // Therefore, we always suppress the scrolling in JS. Because this also suppresses the 'click' // event, click actions have to be triggered manually in the 'mouseup' handler. event.preventDefault(); } protected _onComponentMouseMove(event: JQuery.MouseMoveEvent) { if (!this._moveData.rafId) { this._moveData.rafId = requestAnimationFrame(this._whileComponentMove.bind(this)); } } protected _onComponentMouseUp(event: JQuery.MouseUpEvent) { this._endComponentMove(); } protected _whileComponentMove() { this._moveData.rafId = null; // Ignore small mouse movements if (!this._moveData.moving) { let pixelDistance = new Point( this._moveData.currentCursorPosition.x - this._moveData.startCursorPosition.x, this._moveData.currentCursorPosition.y - this._moveData.startCursorPosition.y); if (Math.abs(pixelDistance.x) < 10 && Math.abs(pixelDistance.y) < 10) { return; } this._moveData.moving = true; this._moveData.component?.closePopup(); } this._updateComponentVirtualPosition(this._moveData.component); } protected _updateComponentVirtualPosition(component: CalendarComponent) { let $part = this._moveData.$movePart; let currDay = $part.closest('.calendar-day').data('day'); let currWeek = $part.closest('.calendar-day').data('week'); if (component.rendered && (currDay !== this._moveData.virtualX || currWeek !== this._moveData.virtualY)) { let newContainer = this._calculateVirtualComponentContainer(); $part.detach().appendTo(newContainer); if (!this.isMonth()) { let topPercentage = (this._moveData.virtualY - this._moveData.virtualOffset) * 100 / 1440; $part.css('top', topPercentage + '%'); } } } protected _calculateVirtualComponentContainer(): JQuery { let baseQuery = $('.calendar-week:not(.hidden) > .calendar-day'); let moveData = this._moveData; if (this.isMonth()) { // Filter calendar days by correct x / y position return baseQuery.filter(function() { return $(this).data('day') === moveData.virtualX && $(this).data('week') === moveData.virtualY; }).find('.resource-column.default > .calendar-scrollable-components'); } if (this.isDay()) { return baseQuery .filter('.selected') .children('.resource-column') .filter(function() { return $(this).data('resourceId') === moveData.virtualResourceId; }); } // When on work week or week, return default resource column return baseQuery.filter(function() { return $(this).data('day') === moveData.virtualX; }).children('.resource-column.default'); } protected _endComponentMove() { if (this._moveData.rafId) { cancelAnimationFrame(this._moveData.rafId); this._moveData.rafId = null; } let component = this._moveData.component; let appointmentFromDate = dates.parseJsonDate(component.fromDate); let appointmentToDate = dates.parseJsonDate(component.toDate); let newDay = this._calculateDateForComponentPart(this._moveData.$movePart); this._moveData.$movePart?.remove(); this._moveData.$movePart = null; if (!newDay) { // Component has not been moved return; } let newTime = this.isMonth() ? this._getTotalMinutesInDay(appointmentFromDate) : this._moveData.virtualY - this._moveData.virtualOffset; let newResourceId = this._moveData.virtualResourceId; let dateShift = dates.compareDays(newDay, dates.trunc(appointmentFromDate)); let timeShiftMinutes = newTime - this._getTotalMinutesInDay(appointmentFromDate); if (!dateShift && !timeShiftMinutes && component.item.resourceId === newResourceId) { // Nothing to shift return; } if (!this.findResourceForId(newResourceId).selectable) { // Not allowed to move a component to a calendar that is not selectable return; } if (component.item && this.isDay()) { component.item.resourceId = this._moveData.virtualResourceId; } if (dateShift) { appointmentFromDate = dates.shift(appointmentFromDate, 0, 0, dateShift); appointmentToDate = dates.shift(appointmentToDate, 0, 0, dateShift); } if (timeShiftMinutes) { let hourShift = Math.trunc(timeShiftMinutes / 60); let minuteShift = Math.round(timeShiftMinutes % 60); appointmentFromDate = dates.shiftTime(appointmentFromDate, hourShift, minuteShift); appointmentToDate = dates.shiftTime(appointmentToDate, hourShift, minuteShift); } component.fromDate = this._format(appointmentFromDate, 'yyyy-MM-dd HH:mm:ss.SSS'); component.toDate = this._format(appointmentToDate, 'yyyy-MM-dd HH:mm:ss.SSS'); component.coveredDaysRange = new DateRange(dates.trunc(appointmentFromDate), dates.trunc(appointmentToDate)); this._renderComponents(); component.applySelection(component._$parts[0]); // Select component after move this.trigger('componentMove', { component: component }); } protected _getCalendarComponentForMouseEvent(event: JQuery.MouseDownEvent): CalendarComponent { let $elem = $(event.target); $elem = $.ensure($elem); while ($elem && $elem.length > 0) { let component = $elem.data('component'); if (component) { return component; } $elem = $elem.parent(); } return null; } protected _updateLeafResources(): void { this._leafResources = this.resources .filter(resource => !this.resources .find(res => res.parentId === resource.resourceId)); } protected _newMoveData(event: JQuery.MouseDownEvent): CalendarMoveData { let moveData: CalendarMoveData = {}; moveData.event = event; moveData.cancel = () => { moveData.cancelled = true; }; moveData.containerOffset = this.$grid.offset(); moveData.containerScrollPosition = new Point(this.get$Scrollable().scrollLeft(), this.get$Scrollable().scrollTop()); moveData.startCursorPosition = new Point( event.pageX - moveData.containerOffset.left + moveData.containerScrollPosition.x, event.pageY - moveData.containerOffset.top + moveData.containerScrollPosition.y ); moveData.currentCursorPosition = moveData.startCursorPosition; return moveData; } /* -- helper ---------------------------------------------------- */ /** @internal */ _dayPosition(hour: number, minutes: number): number { // Height position in percent of total calendar let pos; if (hour < 0) { pos = 0; // All day event } else { pos = 100 / (24 * 60) * (hour * 60 + minutes); } return Math.round(pos * 100) / 100; } protected _format(date: Date, pattern: string): string { return dates.format(date, this.session.locale, pattern); } protected _sortFromTo(c1: CalendarComponent, c2: CalendarComponent): number { let from1 = dates.parseJsonDate(c1.fromDate); let from2 = dates.parseJsonDate(c2.fromDate); let diffFrom = dates.compare(from1, from2); if (diffFrom !== 0) { return diffFrom; } let to1 = dates.parseJsonDate(c1.toDate); let to2 = dates.parseJsonDate(c2.toDate); let diffTo = dates.compare(to1, to2); if (diffTo !== 0) { return diffTo; } let s1 = c1.item && c1.item.subject ? c1.item.subject : ''; let s2 = c2.item && c2.item.subject ? c2.item.subject : ''; return s1.localeCompare(s2); } protected _startRangeSelection(event: JQuery.MouseDownEvent, selectedResourceChanged: boolean) { if (!this.rangeSelectionAllowed || this._rangeSelectionStarted || Device.get().type === Device.Type.MOBILE) { return; } // No range selection when resource is not selectable let currentResourceId = $(event.delegateTarget).data('resourceId'); let selectable = this.findResourceForId(currentResourceId).selectable; if (!selectable) { return; } // Ignore right mouse button clicks within current selection let selectedDateTime = this._getSelectedDateTime(event); if (event.which === 3 && this.selectedRange && selectedDateTime && dates.compare(selectedDateTime, this.selectedRange.from) >= 0 && dates.compare(selectedDateTime, this.selectedRange.to) < 0 && !selectedResourceChanged) { return; } // Ignore clicks to calendar components, this should open the tooltip if (!$(event.target).hasClass('calendar-range-selector') && !$(event.target).hasClass('resource-column')) { return; } // init selector this.selectorStart = this._getSelectedDateTime(event); if (!this.selectorStart) { return; } this.selectorEnd = new Date(this.selectorStart); if (!this.selectorStart || !this.selectorEnd) { return; } this.$window .off('mousemove', this._mouseMoveRangeSelectionHandler) .off('mouseup', this._mouseUpRangeSelectionHandler) .on('mousemove', this._mouseMoveRangeSelectionHandler) .on('mouseup', this._mouseUpRangeSelectionHandler); // draw this._setRangeSelection(); this._rangeSelectionStarted = true; } protected _findDayInCalendar(selectedDate: Date): JQuery { let $foundDay: JQuery = null; $('.calendar-day', this.$container).each((index, element) => { let $day = $(element), date = $day.data('date'); if (dates.compareDays(date, selectedDate) === 0) { $foundDay = $day; } }); return $foundDay; } protected _findSelectedResourceColumn(selectedDate: Date): JQuery { let $day = this._findDayInCalendar(selectedDate); if (!$day || !this.selectedResource) { return null; } return $day .find('.resource-column') .filter((i, e) => $(e).data('resourceId') === this.selectedResource.resourceId); } protected _setRangeSelection() { let selectorFirst: Date, selectorLast: Date; if (!this.selectorStart || !this.selectorEnd) { return; } this._removeRangeSelection(); if (dates.compare(this.selectorStart, this.selectorEnd) > 0) { selectorFirst = this.selectorEnd; selectorLast = this.selectorStart; } else { selectorFirst = this.selectorStart; selectorLast = this.selectorEnd; } let numberOfDays = dates.compareDays(selectorLast, selectorFirst) + 1; if (numberOfDays === 1) { this._appendCalendarRangeSelection(selectorFirst, selectorFirst, selectorLast); } else if (numberOfDays > 1) { this._appendCalendarRangeSelection(selectorFirst, selectorFirst, null); for (let i = 1; i < numberOfDays - 1; i++) { let day = new Date(selectorFirst); day.setDate(day.getDate() + i); this._appendCalendarRangeSelection(day, null, null); } this._appendCalendarRangeSelection(selectorLast, null, selectorLast); } } protected _removeRangeSelection() { $('.calendar-range-selector').remove(); } protected _appendCalendarRangeSelection(date: Date, fromTime: Date, toTime: Date) { let $parent = this._findSelectedResourceColumn(date); if (!$parent) { return; } // top and height let startPosition = fromTime ? this._dayPosition(this._getHours(fromTime), 0) : 0; let endPosition = toTime ? this._dayPosition(this._getHours(toTime) + (1 / this.numberOfHourDivisions), 0) : 100; $parent.appendDiv('calendar-range-selector') .css('top', startPosition + '%') .css('height', endPosition - startPosition + '%'); } protected _getHours(date: Date): number { // round to number of hour divisions return Math.round((date.getHours() + date.getMinutes() / 60) * this.numberOfHourDivisions) / this.numberOfHourDivisions; } protected _onMouseMoveRangeSelection(event: JQuery.MouseMoveEvent) { let selectorEndTime = this._getSelectedDateTime(event); if (selectorEndTime) { this.selectorEnd = selectorEndTime; } this._setRangeSelection(); } protected _onMouseUpRangeSelection(event: JQuery.MouseUpEvent) { if (this._mouseMoveRangeSelectionHandler) { this.$window.off('mousemove', this._mouseMoveRangeSelectionHandler); } if (this._mouseUpRangeSelectionHandler) { this.$window.off('mouseup', this._mouseUpRangeSelectionHandler); } if (!this._rangeSelectionStarted) { return; } this._rangeSelectionStarted = false; if (this.rendered) { this._setRangeSelection(); this._updateSelectedRange(); } } protected _updateSelectedRange() { let start: Date, end: Date; if (this.selectorStart && this.selectorEnd) { if (dates.compare(this.selectorStart, this.selectorEnd) > 0) { start = new Date(this.selectorEnd); end = new Date(this.selectorStart); } else { start = new Date(this.selectorStart); end = new Date(this.selectorEnd); } start.setHours(0, this._getHours(start) * 60); end.setHours(0, this._getHours(end) * 60 + (60 / this.numberOfHourDivisions)); this.selectedRange = new DateRange(start, end); } else { this.selectedRange = null; } this.trigger('selectedRangeChange'); } protected _captureVirtualPositionByContainer($resourceColumn: JQuery) { if (this.isDay()) { this._moveData.virtualResourceId = $resourceColumn.data('resourceId'); } let $calendarDay = $resourceColumn.closest('.calendar-day'); this._moveData.virtualX = $calendarDay.data('day'); if (this.isMonth()) { this._moveData.virtualY = $calendarDay.data('week'); } } protected _captureVirtualPositionByCursor(cursorY: number) { if (!this.isMonth()) { // calculate current cursor time let pxFromTopOfDay = cursorY - this._moveData.containerOffset.top + this._moveData.containerScrollPosition.y; let divisionFromTop = Math.floor(pxFromTopOfDay / this.heightPerDivision); this._moveData.virtualY = Math.round((60 / this.numberOfHourDivisions) * divisionFromTop); } } protected _calculateDateForComponentPart($part: JQuery): Date { let $calendarDay = $part.closest('.calendar-day'); if ($calendarDay.length === 0) { // Component is not attached to div return null; } let dayShift = null; let startDate = this._exactRange.from; if (this.isDay()) { dayShift = 0; } else { dayShift = $calendarDay.data('day') - 1; // -1 because view range starts at first day if (this.isMonth()) { startDate = this.viewRange.from; // When on month, more days are shown than the exact range contains let week = $calendarDay.data('week') - 1; // -1 because week starts at 1 dayShift += week * 7; } } return dates.trunc(dates.shift(startDate, 0, 0, dayShift)); } protected _getTotalMinutesInDay(date: Date) { return date.getHours() * 60 + date.getMinutes(); } }