/* 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<Number>} 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 = Math.min(
Math.max(
month,
selectedYear === minYear ?
this.state.min.getMonth() : 0
),
selectedYear === maxYear ?
this.state.max.getMonth() : 11
);
}
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<Object>}
* {
* {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<Object>}
* {
* {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<Object>}
* {
* {Date} dateObj
* {Number} content
* {Array<String>} 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<Number>} weekends
* @return {Array<Object>}
* {
* {Number} content
* {Array<String>} 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));
},
};
}