Skip to content
Snippets Groups Projects
month-calendar.mjs 17.55 KiB
/**
 * 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);