/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. */ import { instanceSymbol } from "../../constants.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, } from "../../dom/constants.mjs"; import { CustomControl } from "../../dom/customcontrol.mjs"; import { CustomElement, getSlottedElements, initMethodSymbol, } from "../../dom/customelement.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent } from "../../dom/events.mjs"; import { isFunction, isString } from "../../types/is.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { addErrorAttribute } from "../../dom/error.mjs"; import { MonthCalendarStyleSheet } from "./stylesheet/month-calendar.mjs"; import { datasourceLinkedElementSymbol, handleDataSourceChanges, } from "../datatable/util.mjs"; import { findElementWithSelectorUpwards } from "../../dom/util.mjs"; import { Datasource } from "../datatable/datasource.mjs"; import { Observer } from "../../types/observer.mjs"; export { MonthCalendar }; /** * @private * @type {symbol} */ export const calendarElementSymbol = Symbol("calendarElement"); /** * A Calendar * * @fragments /fragments/components/time/calendar/ * * @example /examples/components/time/calendar-simple * * @since 3.112.0 * @copyright schukai GmbH * @summary A beautiful Calendar that can make your life easier and also looks good. */ class MonthCalendar extends CustomElement { /** * This method is called by the `instanceof` operator. * @returns {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/time/calendar@@instance"); } [initMethodSymbol]() { super[initMethodSymbol](); const def = generateCalendarData.call(this); this.setOption("calendarDays", def.calendarDays); this.setOption("calendarWeekdays", def.calendarWeekdays); } /** * * @return {Components.Time.Calendar */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initDataSource.call(this); initEventHandler.call(this); return this; } /** * To set the options via the HTML Tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} labels Label definitions * @property {Object} actions Callbacks * @property {string} actions.click="throw Error" Callback when clicked * @property {Object} features Features * @property {Object} classes CSS classes * @property {boolean} disabled=false Disabled state */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: {}, classes: {}, disabled: false, features: {}, actions: {}, locale: { weekdayFormat: "short", }, startDate: "", calendarDays: [], calendarWeekdays: [], data: [], datasource: { selector: null, }, }); } /** * This method is called when the component is created. * @return {Promise} */ refresh() { // makes sure that handleDataSourceChanges is called return new Promise((resolve) => { this.setOption("data", {}); queueMicrotask(() => { handleDataSourceChanges.call(this); placeAppointments(); resolve(); }); }); } /** * @return {string} */ static getTag() { return "monster-month-calendar"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [MonthCalendarStyleSheet]; } } /** * Calculates how many days of an appointment are distributed across calendar rows (weeks). * Uses the start date of the calendar grid (e.g., from generateCalendarData()) as a reference. * * @param {Date} appointmentStart - Start date of the appointment. * @param {Date} appointmentEnd - End date of the appointment (inclusive). * @param {Date} calendarGridStart - The first day of the calendar grid (e.g., the Monday from generateCalendarData()). * @returns {number[]} Array indicating how many days the appointment spans per row. * * Example: * - Appointment: 01.03.2025 (Saturday) to 01.03.2025: * -> getAppointmentRowsUsingCalendar(new Date("2025-03-01"), new Date("2025-03-01"), calendarGridStart) * returns: [1] (since it occupies only one day in the first row, starting at column 6). * * - Appointment: 01.03.2025 to 03.03.2025: * -> returns: [2, 1] (first row: Saturday and Sunday, second row: Monday). */ function getAppointmentRowsUsingCalendar( appointmentStart, appointmentEnd, calendarGridStart, ) { const oneDayMs = 24 * 60 * 60 * 1000; // Calculate the offset (in days) from the calendar start to the appointment start const offset = Math.floor((appointmentStart - calendarGridStart) / oneDayMs); // Determine the column index in the calendar row (Monday = 0, ..., Sunday = 6) let startColumn = offset % 7; if (startColumn < 0) { startColumn += 7; } // Calculate the total number of days for the appointment (including start and end date) const totalDays = Math.floor((appointmentEnd - appointmentStart) / oneDayMs) + 1; // The first calendar block can accommodate at most (7 - startColumn) days. const firstRowDays = Math.min(totalDays, 7 - startColumn); const rows = [firstRowDays]; let remainingDays = totalDays - firstRowDays; // Handle full weeks (7 days per row) while (remainingDays > 7) { rows.push(7); remainingDays -= 7; } // Handle the last row if there are any remaining days if (remainingDays > 0) { rows.push(remainingDays); } return rows; } /** * @private * @param format * @returns {*[]} */ function getWeekdays(format = "long") { const locale = getLocaleOfDocument(); const weekdays = []; for (let i = 1; i < 8; i++) { const date = new Date(1970, 0, 4 + i); // 4. Jan. 1970 = Sonntag weekdays.push( new Intl.DateTimeFormat(locale, { weekday: format }).format(date), ); } return weekdays; } /** * Assigns a "line" property to the provided segments (with "startIndex" and "columns"). * It checks for horizontal overlaps within each calendar row (7 boxes). * Always assigns the lowest available "line". * * @private * * @param {Array} segments - Array of segments, e.g. * [ * {"columns":6,"label":"03/11/2025 - 04/05/2025","start":"2025-03-11","startIndex":15}, * {"columns":7,"label":"03/11/2025 - 04/05/2025","start":"2025-03-17","startIndex":21}, * {"columns":7,"label":"03/11/2025 - 04/05/2025","start":"2025-03-24","startIndex":28}, * {"columns":6,"label":"03/11/2025 - 04/05/2025","start":"2025-03-31","startIndex":35} * ] * @returns {Array} The segments with assigned "line" property */ function assignLinesToSegments(segments) { const groups = {}; segments.forEach((segment) => { const week = Math.floor(segment.startIndex / 7); if (!groups[week]) { groups[week] = []; } groups[week].push(segment); }); Object.keys(groups).forEach((weekKey) => { const weekSegments = groups[weekKey]; weekSegments.sort((a, b) => a.startIndex - b.startIndex); const lineEnds = []; weekSegments.forEach((segment) => { const segStart = segment.startIndex; const segEnd = segment.startIndex + segment.columns - 1; let placed = false; for (let line = 0; line < lineEnds.length; line++) { if (segStart >= lineEnds[line] + 1) { segment.line = line; lineEnds[line] = segEnd; placed = true; break; } } if (!placed) { segment.line = lineEnds.length; lineEnds.push(segEnd); } }); }); return segments; } /** * @private */ function initDataSource() { setTimeout(() => { if (!this[datasourceLinkedElementSymbol]) { const selector = this.getOption("datasource.selector"); if (isString(selector)) { const element = findElementWithSelectorUpwards(this, selector); if (element === null) { addErrorAttribute( this, "the selector must match exactly one element", ); return; } if (!(element instanceof Datasource)) { addErrorAttribute(this, "the element must be a datasource"); return; } this[datasourceLinkedElementSymbol] = element; element.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); handleDataSourceChanges.call(this); placeAppointments.call(this); } else { addErrorAttribute( this, "the datasource selector is missing or invalid", ); } } }, 10); } function placeAppointments() { const self = this; const currentWithOfGridCell = this[calendarElementSymbol].getBoundingClientRect().width / 7; const appointments = this.getOption("data"); const segments = []; let maxLineHeight = 0; appointments.forEach((appointment) => { if (!appointment?.startDate || !appointment?.endDate) { addErrorAttribute(this, "Missing start or end date in appointment"); return; } const startDate = appointment?.startDate; let container = self.shadowRoot.querySelector( `[data-monster-day="${startDate}"]`, ); if (!container) { addErrorAttribute( this, "Invalid, missing or out of range date in appointment" + startDate, ); return; } // calc length of appointment const start = new Date(startDate); const end = new Date(appointment?.endDate); const calendarDays = this.getOption("calendarDays"); const appointmentRows = getAppointmentRowsUsingCalendar( start, end, calendarDays[0].date, ); let date = appointment.startDate; const label = start !== end ? `${start.toLocaleDateString()} - ${end.toLocaleDateString()}` : start.toLocaleDateString(); for (let i = 0; i < appointmentRows.length; i++) { const cols = appointmentRows[i]; const calendarStartDate = new Date(calendarDays[0].date); // First day of the calendar grid const appointmentDate = new Date(date); const startIndex = Math.floor( (appointmentDate - calendarStartDate) / (24 * 60 * 60 * 1000), ); segments.push({ columns: cols, label: label, start: date, startIndex: startIndex, appointment: appointment, }); maxLineHeight = Math.max(maxLineHeight, getTextHeight.call(this, label)); const nextKeyDate = new Date(start.setDate(start.getDate() + cols)); date = nextKeyDate.getFullYear() + "-" + ("00" + (nextKeyDate.getMonth() + 1)).slice(-2) + "-" + ("00" + nextKeyDate.getDate()).slice(-2); } }); let container = null; const sortedSegments = assignLinesToSegments(segments); for (let i = 0; i < sortedSegments.length; i++) { const segment = sortedSegments[i]; container = self.shadowRoot.querySelector( `[data-monster-day="${segment.start}"]`, ); if (!container) { addErrorAttribute( this, "Invalid, missing or out of range date in appointment" + segment.start, ); return; } const appointmentSegment = document.createElement( "monster-appointment-segment", ); appointmentSegment.className = "appointment-segment"; appointmentSegment.style.backgroundColor = segment.appointment.color; // search a color that is readable on the background color const rgb = appointmentSegment.style.backgroundColor.match(/\d+/g); const brightness = Math.round( (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000, ); if (brightness > 125) { appointmentSegment.style.color = "#000000"; } else { appointmentSegment.style.color = "#ffffff"; } appointmentSegment.style.width = `${currentWithOfGridCell * segment.columns}px`; appointmentSegment.style.height = maxLineHeight + "px"; appointmentSegment.style.top = `${segment.line * maxLineHeight + maxLineHeight + 10}px`; appointmentSegment.setOption("labels.text", segment.label); container.appendChild(appointmentSegment); } } /** * Generates two arrays: one for the calendar grid (42 days) and one for the weekday headers (7 days). * The grid always starts on the Monday of the week that contains the 1st of the given month. * * @returns {Object} An object containing: * - calendarDays: Array of 42 objects, each representing a day. * - calendarWeekdays: Array of 7 objects, each representing a weekday header. */ function generateCalendarData() { let selectedDate = this.getOption("startDate"); if (!(selectedDate instanceof Date)) { if (typeof selectedDate === "string") { try { selectedDate = new Date(selectedDate); } catch (e) { addErrorAttribute(this, "Invalid calendar date"); return { calendarDays, calendarWeekdays }; } } else { addErrorAttribute(this, "Invalid calendar date"); return { calendarDays, calendarWeekdays }; } } const calendarDays = []; let calendarWeekdays = []; if (!(selectedDate instanceof Date)) { addErrorAttribute(this, "Invalid calendar date"); return { calendarDays, calendarWeekdays }; } // Get the year and month from the provided date const year = selectedDate.getFullYear(); const month = selectedDate.getMonth(); // 0-based index (0 = January) // Create a Date object for the 1st of the given month const firstDayOfMonth = new Date(year, month, 1); // Determine the weekday index of the 1st day, ensuring Monday = 0 const weekdayIndex = (firstDayOfMonth.getDay() + 6) % 7; // Calculate the start date: move backward to the Monday of the starting week const startDate = new Date(firstDayOfMonth); startDate.setDate(firstDayOfMonth.getDate() - weekdayIndex); // Generate 42 days (6 weeks × 7 days) for (let i = 0; i < 42; i++) { const current = new Date(startDate); current.setDate(startDate.getDate() + i); const label = current.getDate().toString(); calendarDays.push({ date: current, //day: current.getDate(), month: current.getMonth() + 1, // 1-based month (1-12) year: current.getFullYear(), isCurrentMonth: current.getMonth() === month, label: label, index: i, day: current.getFullYear() + "-" + ("00" + (current.getMonth() + 1)).slice(-2) + "-" + ("00" + current.getDate()).slice(-2), classes: "day-cell " + (current.getMonth() === month ? "current-month" : "other-month") + (current.getDay() === 0 || current.getDay() === 6 ? " weekend" : "") + (current.toDateString() === new Date().toDateString() ? " today" : ""), }); } // Generate weekday header array (Monday through Sunday) let format = this.getOption("locale.weekdayFormat"); if (!["long", "short", "narrow"].includes(format)) { addErrorAttribute(this, "Invalid weekday format option " + format); format = "short"; } const weekdayNames = getWeekdays(format); calendarWeekdays = weekdayNames.map((name, index) => { return { label: name, index: index, }; }); return { calendarDays, calendarWeekdays }; } /** * @private * @return {initEventHandler} * @fires monster-calendar-clicked */ function initEventHandler() { const self = this; this.attachObserver( new Observer(() => { placeAppointments.call(this); }), ); return this; } function getTextHeight(text) { // Ein unsichtbares div erstellen const div = document.createElement("div"); div.style.position = "absolute"; div.style.whiteSpace = "nowrap"; div.style.visibility = "hidden"; div.textContent = text; this.shadowRoot.appendChild(div); const height = div.clientHeight; this.shadowRoot.removeChild(div); return height; } /** * @private * @return {void} */ function initControlReferences() { this[calendarElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="control"]`, ); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="cell"> <div data-monster-attributes="class path:cell.classes, data-monster-index path:cell.index"> <div data-monster-replace="path:cell.label"></div> <div data-monster-role="appointment-container" data-monster-attributes="data-monster-day path:cell.day, data-monster-calendar-index path:cell.index"></div> </div> </template> <template id="calendar-weekday-header"> <div data-monster-attributes="class path:calendar-weekday-header.classes, data-monster-index path:calendar-weekday-header.index" data-monster-replace="path:calendar-weekday-header.label"></div> </template> <div data-monster-role="control" part="control"> <div class="weekday-header"> <div data-monster-role="weekdays" data-monster-insert="calendar-weekday-header path:calendarWeekdays"></div> <div class="calendar-body"> <div data-monster-role="appointments"> <slot></slot> </div> <div data-monster-role="cells" data-monster-insert="cell path:calendarDays"></div> </div> </div> `; } registerCustomElement(MonthCalendar);