/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; /** * DateKeeper keeps track of the date states. */ function DateKeeper(props) { this.init(props); } { const DAYS_IN_A_WEEK = 7, MONTHS_IN_A_YEAR = 12, YEAR_VIEW_SIZE = 200, YEAR_BUFFER_SIZE = 10, // The min value is 0001-01-01 based on HTML spec: // https://html.spec.whatwg.org/#valid-date-string MIN_DATE = -62135596800000, // The max value is derived from the ECMAScript spec (275760-09-13): // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1 MAX_DATE = 8640000000000000, MAX_YEAR = 275760, MAX_MONTH = 9, // One day in ms since epoch. ONE_DAY = 86400000; DateKeeper.prototype = { get year() { return this.state.dateObj.getUTCFullYear(); }, get month() { return this.state.dateObj.getUTCMonth(); }, get selection() { return this.state.selection; }, /** * Initialize DateKeeper * @param {Number} year * @param {Number} month * @param {Number} day * @param {Number} min * @param {Number} max * @param {Number} step * @param {Number} stepBase * @param {Number} firstDayOfWeek * @param {Array} weekends * @param {Number} calViewSize */ init({ year, month, day, min, max, step, stepBase, firstDayOfWeek = 0, weekends = [0], calViewSize = 42, }) { const today = new Date(); this.state = { step, firstDayOfWeek, weekends, calViewSize, // min & max are NaN if empty or invalid min: new Date(Number.isNaN(min) ? MIN_DATE : min), max: new Date(Number.isNaN(max) ? MAX_DATE : max), stepBase: new Date(stepBase), today: this._newUTCDate( today.getFullYear(), today.getMonth(), today.getDate() ), weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends), years: [], dateObj: new Date(0), selection: { year, month, day }, }; if (year === undefined) { year = today.getFullYear(); } if (month === undefined) { month = today.getMonth(); } const minYear = this.state.min.getFullYear(); const maxYear = this.state.max.getFullYear(); // Choose a valid year for the value/min/max properties const selectedYear = Math.min(Math.max(year, minYear), maxYear); // Choose the month that correspond to the selectedYear let selectedMonth = 0; if (selectedYear === year) { selectedMonth = month; } else if (selectedYear === minYear) { selectedMonth = this.state.min.getMonth(); } else if (selectedYear === maxYear) { selectedMonth = this.state.max.getMonth(); } this.setCalendarMonth({ year: selectedYear, month: selectedMonth, }); }, /** * Set new calendar month. The year is always treated as full year, so the * short-form is not supported. * @param {Object} date parts * { * {Number} year [optional] * {Number} month [optional] * } */ setCalendarMonth({ year = this.year, month = this.month }) { // Make sure the date is valid before setting. // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 if (year > MAX_YEAR || (year === MAX_YEAR && month >= MAX_MONTH)) { this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1); } else if (year < 1 || (year === 1 && month < 0)) { this.state.dateObj.setUTCFullYear(1, 0, 1); } else { this.state.dateObj.setUTCFullYear(year, month, 1); } }, /** * Set selection date * @param {Number} year * @param {Number} month * @param {Number} day */ setSelection({ year, month, day }) { this.state.selection.year = year; this.state.selection.month = month; this.state.selection.day = day; }, /** * Set month. Makes sure the day is <= the last day of the month * @param {Number} month */ setMonth(month) { this.setCalendarMonth({ year: this.year, month }); }, /** * Set year. Makes sure the day is <= the last day of the month * @param {Number} year */ setYear(year) { this.setCalendarMonth({ year, month: this.month }); }, /** * Set month by offset. Makes sure the day is <= the last day of the month * @param {Number} offset */ setMonthByOffset(offset) { this.setCalendarMonth({ year: this.year, month: this.month + offset }); }, /** * Generate the array of months * @return {Array} * { * {Number} value: Month in int * {Boolean} enabled * } */ getMonths() { let months = []; const currentYear = this.year; const minYear = this.state.min.getFullYear(); const minMonth = this.state.min.getMonth(); const maxYear = this.state.max.getFullYear(); const maxMonth = this.state.max.getMonth(); for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { const disabled = (currentYear == minYear && i < minMonth) || (currentYear == maxYear && i > maxMonth); months.push({ value: i, enabled: !disabled, }); } return months; }, /** * Generate the array of years * @return {Array} * { * {Number} value: Year in int * {Boolean} enabled * } */ getYears() { let years = []; const firstItem = this.state.years[0]; const lastItem = this.state.years[this.state.years.length - 1]; const currentYear = this.year; const minYear = Math.max(this.state.min.getFullYear(), 1); const maxYear = Math.min(this.state.max.getFullYear(), MAX_YEAR); // Generate new years array when the year is outside of the first & // last item range. If not, return the cached result. if ( !firstItem || !lastItem || currentYear <= firstItem.value + YEAR_BUFFER_SIZE || currentYear >= lastItem.value - YEAR_BUFFER_SIZE ) { // The year is set in the middle with items on both directions for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) { const year = currentYear + i; if (year >= minYear && year <= maxYear) { years.push({ value: year, enabled: true, }); } } this.state.years = years; } return this.state.years; }, /** * Get days for calendar * @return {Array} * { * {Date} dateObj * {Number} content * {Array} classNames * {Boolean} enabled * } */ getDays() { const firstDayOfMonth = this._getFirstCalendarDate( this.state.dateObj, this.state.firstDayOfWeek ); const month = this.month; let days = []; for (let i = 0; i < this.state.calViewSize; i++) { const dateObj = this._newUTCDate( firstDayOfMonth.getUTCFullYear(), firstDayOfMonth.getUTCMonth(), firstDayOfMonth.getUTCDate() + i ); let classNames = []; let enabled = true; const isValid = dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE; if (!isValid) { classNames.push("out-of-range"); enabled = false; days.push({ classNames, enabled, }); continue; } const isWeekend = this.state.weekends.includes(dateObj.getUTCDay()); const isCurrentMonth = month == dateObj.getUTCMonth(); const isSelection = this.state.selection.year == dateObj.getUTCFullYear() && this.state.selection.month == dateObj.getUTCMonth() && this.state.selection.day == dateObj.getUTCDate(); // The date is at 00:00, so if the minimum is that day at e.g. 01:00, // we should arguably still be able to select that date. So we need to // compare the date at the very end of the day for minimum purposes. const isOutOfRange = dateObj.getTime() + ONE_DAY - 1 < this.state.min.getTime() || dateObj.getTime() > this.state.max.getTime(); const isToday = this.state.today.getTime() == dateObj.getTime(); const isOffStep = this._checkIsOffStep( dateObj, this._newUTCDate( dateObj.getUTCFullYear(), dateObj.getUTCMonth(), dateObj.getUTCDate() + 1 ) ); if (isWeekend) { classNames.push("weekend"); } if (!isCurrentMonth) { classNames.push("outside"); } if (isSelection && !isOutOfRange && !isOffStep) { classNames.push("selection"); } if (isOutOfRange) { classNames.push("out-of-range"); enabled = false; } if (isToday) { classNames.push("today"); } if (isOffStep) { classNames.push("off-step"); enabled = false; } days.push({ dateObj, content: dateObj.getUTCDate(), classNames, enabled, }); } return days; }, /** * Check if a date is off step given a starting point and the next increment * @param {Date} start * @param {Date} next * @return {Boolean} */ _checkIsOffStep(start, next) { // If the increment is larger or equal to the step, it must not be off-step. if (next - start >= this.state.step) { return false; } // Calculate the last valid date const lastValidStep = Math.floor( (next - 1 - this.state.stepBase) / this.state.step ); const lastValidTimeInMs = lastValidStep * this.state.step + this.state.stepBase.getTime(); // The date is off-step if the last valid date is smaller than the start date return lastValidTimeInMs < start.getTime(); }, /** * Get week headers for calendar * @param {Number} firstDayOfWeek * @param {Array} weekends * @return {Array} * { * {Number} content * {Array} classNames * } */ _getWeekHeaders(firstDayOfWeek, weekends) { let headers = []; let dayOfWeek = firstDayOfWeek; for (let i = 0; i < DAYS_IN_A_WEEK; i++) { headers.push({ content: dayOfWeek % DAYS_IN_A_WEEK, classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) ? ["weekend"] : [], }); dayOfWeek++; } return headers; }, /** * Get the first day on a calendar month * @param {Date} dateObj * @param {Number} firstDayOfWeek * @return {Date} */ _getFirstCalendarDate(dateObj, firstDayOfWeek) { const daysOffset = 1 - DAYS_IN_A_WEEK; let firstDayOfMonth = this._newUTCDate( dateObj.getUTCFullYear(), dateObj.getUTCMonth() ); let dayOfWeek = firstDayOfMonth.getUTCDay(); return this._newUTCDate( firstDayOfMonth.getUTCFullYear(), firstDayOfMonth.getUTCMonth(), // When first calendar date is the same as first day of the week, add // another row on top of it. firstDayOfWeek == dayOfWeek ? daysOffset : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK ); }, /** * Helper function for creating UTC dates * @param {...[Number]} parts * @return {Date} */ _newUTCDate(...parts) { return new Date(new Date(0).setUTCFullYear(...parts)); }, }; }