/** @module caltime/timerule
*
* @copyright Michael McCarthy <michael.mccarthy@ieee.org> 2017
* @license MIT
*/
'use strict';
/* dependencies */
const _ = require('lodash');
const moment = require('moment-timezone');
const dateSpan = require('./datespan').dateSpan;
const timeZone = require('./timezone').timeZone;
const modconst = require('./constants');
/**
* Functional constructor. Creates an instance of a TimeRule.
* Each TimeRule object is immutable.
* @param {Object} inTimeSpan A TimeSpan object which represents the begin time
* and duration of a span of time during which the rule applies. An error is
* thrown if this argument is not specified or it is not a valid object.
* @param {number} inConstraint Integer. Identifies which type of Constraint
* is applied by the time rule. Valid values are:
* CONSTRAINT_DAY_OF_WEEK, CONSTRAINT_DAY_OF_MONTH, CONSTRAINT_FIRST_OF_MONTH,
* CONSTRAINT_SECOND_OF_MONTH, CONSTRAINT_THIRD_OF_MONTH,
* CONSTRAINT_FOURTH_OF_MONTH, CONSTRAINT_FIFTH_OF_MONTH and
* CONSTRAINT_LAST_OF_MONTH.
* @param {number} inDay Integer. Represents the day of the week or month
* when this rule applies. Values representing the days of the week are the
* same as those of the Date.getDay() method and values for the day of the
* month must be in the range 1-31. Whether the day of the week or month is
* specified depends on the value passed to inConstraint.
* An error is thrown if no value is specified or it is outside the valid range.
* @param {string} inTZ Timezone identifier as defined by the
* tz database - sometimes called the TZ environment variable value.
* See https://www.iana.org/time-zones for more details.
* @param {Date|null} [inBegin] Date and time from which the rule is applied.
* If no begin time is specified then the rule begins at the earliest possible
* time.
* @param {Date|null} [inEnd] Date and time up until which the rule is applied.
* If no begin time is specified then the rule ends at the latest possible time.
* @return {Object} A new instance of a TimeRule object.
*/
let timeRule = function timeRuleFunc(inTimeSpan,
inConstraint,
inDay,
inTZ,
inBegin=null,
inEnd=null) {
/** private *****************************************************************/
/* new instance of object */
const that = {};
if (_.isObject(inTimeSpan) === false) {
throw new Error('Null or non-valid time-span argument passed to method.');
}
if (_.isInteger(inConstraint) === false) {
throw new Error('Null or non-valid constraint argument passed to method.');
}
if (_.isInteger(inDay) === false) {
throw new Error('Null or non-valid day argument passed to method.');
}
if (_.isNil(inBegin) === false
&& _.isDate(inBegin) === false) {
throw new Error('Invalid argument. inBegin must be null or a Date object.');
}
if (_.isNil(inEnd) === false
&& _.isDate(inEnd) === false) {
throw new Error('Invalid argument. inEnd must be null or a Date object.');
}
if ((inConstraint !== modconst.CONSTRAINT_DAY_OF_MONTH)
&& (inDay < modconst.SUNDAY
|| inDay > modconst.SATURDAY)) {
throw new Error('Invalid argument. Day of the week expected for inDay.');
} else if (inConstraint === modconst.CONSTRAINT_DAY_OF_MONTH
&& (inDay < 1
|| inDay > 31)) {
throw new Error('Invalid argument. Day of month expected for inDay.');
}
if (_.isString(inTZ) === false
|| moment.tz.zone(inTZ) === null
|| moment.tz('2001-01-01', inTZ).isValid() === false) {
throw new Error('Invalid argument. Invalid TZ identifier string.');
}
if (inConstraint < modconst.CONSTRAINT_MIN_VALUE
|| inConstraint > modconst.CONSTRAINT_MAX_VALUE) {
throw new Error('Invalid argument. inConstraint outside permitted range.');
}
/* timespan representing a span of time during the day */
const timespan = inTimeSpan;
/* constraint applied to the rule */
const constraint = inConstraint;
/* day of week/month when the rule applies. */
const day = inDay;
/* timezone identifier string */
const tz = inTZ;
/* timezone object */
const timezone = timeZone(inTZ);
/* begin time of rule */
const ruleBegin = (_.isDate(inBegin))?inBegin:new Date(Number.MIN_SAFE_INTEGER);
/* end time of rule */
const ruleEnd = (_.isDate(inEnd))?inEnd:new Date(Number.MAX_SAFE_INTEGER);
/** public methods **********************************************************/
/**
* Get the TimeSpan of the TimeRule.
* @return {Object} TimeSpan object.
*/
that.getTimeSpan = function getTimeSpanFunc() {
return timespan;
};
/**
* Get the day of the week or month of the rule.
* @return {number} Day of the week/month. Can be a value in the range 0-31.
*/
that.getDay = function getDayFunc() {
return day;
};
/**
* Get the timezone identifier string of the TimeRule.
* @return {string} Timezone identifier.
*/
that.getTZ = function getTZFunc() {
return tz;
};
/**
* Get the begin time when the rule starts applying.
* @return {Date} Begin time of the rule.
*/
that.getBegin = function getBeginFunc() {
return ruleBegin;
};
/**
* Get the end time when the rule stops applying.
* @return {Date} End time of the rule.
*/
that.getEnd = function getEndFunc() {
return ruleEnd;
};
/**
* Starting from a specific date and time, get all of the date-spans which are
* created by this time rule.
* @param {Date} inBegin Time from which the rule should generate date-spans.
* Must be a valid Date object.
* @param {Date} inEnd Time up to which the rule generates date-spans. Must be
* a valid Date object and after the inBegin time.
* @return {Object[]} Array of DateSpan objects.
*/
that.generateDateSpans = function generateDateSpansFunc(inBegin, inEnd) {
let retval = [];
let beginCounter = null;
let endCounter = null;
let searchBegin = null;
let searchEnd = null;
if (_.isDate(inBegin) === false
|| _.isDate(inEnd) === false) {
throw new Error('Invalid argument. Must be of type Date.');
} else if (inBegin.getTime() > inEnd.getTime()) {
throw new Error('Invalid argument. End date preceeds start date.');
}
/* rules have their own start/end times - no point in searching outside
the rule's own range of dates */
searchBegin = (inBegin.getTime() < ruleBegin.getTime())?ruleBegin:inBegin;
searchEnd = (inEnd.getTime() > ruleEnd.getTime())?ruleEnd:inEnd;
/* setup first day to examine for date-spans */
beginCounter = moment.tz(searchBegin, tz);
endCounter = beginCounter.clone();
endCounter = moment.tz(timezone.nextMidnight(endCounter.toDate()), tz);
/* step thru dates, one day at a time */
while (beginCounter.toDate().getTime() < searchEnd.getTime()) {
/* don't generate periods after the end time on last day */
if (endCounter.toDate().getTime() > searchEnd.getTime()) {
endCounter = moment.tz(searchEnd, tz);
}
let newSpan = generateDateSpan(beginCounter.toDate(), endCounter.toDate());
if (newSpan !== null) {
retval.push(newSpan);
}
beginCounter.add(1, 'days');
beginCounter.startOf('date');
endCounter = beginCounter.clone();
endCounter.add(1, 'days');
endCounter.startOf('date');
}
return retval;
};
/** private methods *********************************************************/
/**
* Search for a date-span for this rule between the start and end dates. The
* begin and end times should belong to the same day. The search is performed
* on a day-by-day basis in the required timezone.
* @param {Date} inBegin Start time. Must be a valid Date object.
* @param {Date} inEnd End time. Must be a valid Date object.
* @return {@link module:date-spans/datespan|null} A single DateSpan object or null
* if the rule generates no date-span between the start and end times.
*/
const generateDateSpan = function generateDateSpanFunc(inBegin, inEnd) {
let retval = null;
let daySpan = null;
let newDateSpan = null;
let beginMoment = null;
if (_.isDate(inBegin) === false
|| _.isDate(inEnd) === false) {
throw new Error('Invalid argument. Function expects two Date objects.');
} else if (inBegin.getTime() >= inEnd.getTime()) {
throw new Error('Invalid argument. Start date must preceed the end date.');
}
beginMoment = moment.tz(inBegin, tz);
daySpan = dateSpan(inBegin, inEnd);
if (beginMoment === null
|| daySpan === null) {
throw new Error('Internal Error. Object not expected to be null.');
}
/* check if constraint matches parameters of the day */
if ((constraint === modconst.CONSTRAINT_DAY_OF_WEEK
&& beginMoment.day() === day)
|| (constraint === modconst.CONSTRAINT_DAY_OF_MONTH
&& beginMoment.date() === day)) {
newDateSpan = makeDateSpan(inBegin);
} else if ((constraint !== modconst.CONSTRAINT_DAY_OF_WEEK
&& constraint !== modconst.CONSTRAINT_DAY_OF_MONTH)
&& beginMoment.date() === findDayOfMonth(constraint,
day,
beginMoment.year(),
beginMoment.month())) {
newDateSpan = makeDateSpan(inBegin);
}
/* rule made a date-span - yipee */
if (newDateSpan !== null) {
retval = newDateSpan.intersect(daySpan);
}
return retval;
};
/**
* Create a DateSpan object based on three inputs:
* - a specific date based on a Date object.
* - time of the day based on the time associated with the rule.
* - duration based on the duration of the rule.
* @param {Date} inDate Date for which the date-span should be created. Must
* be a valid Date object.
* @return {@link module:date-spans/datespan} A single DateSpan object.
*/
const makeDateSpan = function makeDateSpanFunc(inDate) {
let retval = null;
let ruleMoment = null;
if (_.isDate(inDate) === false) {
throw new Error('Invalid argument. Function expects a Date object.');
}
ruleMoment = moment.tz(inDate, tz);
if (ruleMoment === null) {
throw new Error('Internal Error. Object not expected to be null.');
}
ruleMoment = moment.tz(inDate, tz);
ruleMoment.hour(timespan.getHours());
ruleMoment.minutes(timespan.getMinutes());
ruleMoment.seconds(timespan.getSeconds());
ruleMoment.milliseconds(timespan.getMilliseconds());
// use UTC time because time in rule has a timezone
retval = dateSpan(ruleMoment.utc().toDate(),
null,
timespan.getDurationMins(),
timespan.getDurationSecs(),
timespan.getDurationMs());
return retval;
};
/**
* Find the day of the month which matches the constraint. The constraint
* must be of the day-of-week type i.e. CONSTRAINT_FIRST_OF_MONTH.
* @param {number} inConstraint Integer. Constraint type.
* @param {number} inDay Integer. Name of the day.
* @param {number} inYear Integer. Year component of the date.
* @param {number} inMonth Integer. Month component of the date. Range is 0-11.
* @return {number} Integer which indicates the day of the month which
* matches the constraint. Value should be in the range 1-31.
* @throws {Error}
*/
const findDayOfMonth = function findDayOfMonthFunc(inConstraint,
inDay,
inYear,
inMonth) {
let retval = 0;
let tempMoment = null;
// used to find first, second etc. instance of a day during the month
let multiplier = 0;
if (_.isInteger(inConstraint) === false
|| _.isInteger(inDay) === false
|| _.isInteger(inYear) === false
|| _.isInteger(inMonth) === false) {
throw new Error('Invalid argument. Arguments must be integers.');
}
if (inConstraint !== modconst.CONSTRAINT_FIRST_OF_MONTH
&& inConstraint !== modconst.CONSTRAINT_SECOND_OF_MONTH
&& inConstraint !== modconst.CONSTRAINT_THIRD_OF_MONTH
&& inConstraint !== modconst.CONSTRAINT_FOURTH_OF_MONTH
&& inConstraint !== modconst.CONSTRAINT_FIFTH_OF_MONTH
&& inConstraint !== modconst.CONSTRAINT_LAST_OF_MONTH) {
throw new Error('Invalid argument. Not the expeceted constraint type.');
}
if (inDay < modconst.DAY_MIN
|| inDay > modconst.DAY_MAX) {
throw new Error('Invalid argument. Day of week outside permitted range.');
}
if (inMonth < modconst.MONTH_MIN
|| inMonth > modconst.MONTH_MAX) {
throw new Error('Invalid argument. Month is outside permitted range.');
}
tempMoment = moment.tz('2000-01-01 12:00', tz);
tempMoment.year(inYear);
tempMoment.month(inMonth);
tempMoment.startOf('month');
/* last *day of month is one week backwards from first *day of next month */
if (inConstraint == modconst.CONSTRAINT_LAST_OF_MONTH) {
tempMoment.add(1, 'months');
tempMoment.day(inDay);
if (tempMoment.month() > inMonth) {
tempMoment.subtract(7, 'days');
}
if (tempMoment.year() === inYear
&& tempMoment.month() === inMonth
&& tempMoment.day() === inDay) {
retval = tempMoment.date();
} else {
throw new Error('Error calculating "last of month" constraint.');
}
} else {
tempMoment.day(inDay);
switch (inConstraint) {
case modconst.CONSTRAINT_SECOND_OF_MONTH:
multiplier = 1;
break;
case modconst.CONSTRAINT_THIRD_OF_MONTH:
multiplier = 2;
break;
case modconst.CONSTRAINT_FOURTH_OF_MONTH:
multiplier = 3;
break;
case modconst.CONSTRAINT_FIFTH_OF_MONTH:
multiplier = 4;
break;
}
// make sure setting day didn't bump moment back to previous month
if (tempMoment.month() < inMonth) {
tempMoment.add(7, 'days');
}
tempMoment.add(7*multiplier, 'days');
if (tempMoment.year() === inYear
&& tempMoment.month() === inMonth
&& tempMoment.day() === inDay) {
retval = tempMoment.date();
} else {
throw new Error('Error calculating with "*th of month" constraint.');
}
}
return retval;
};
/* functional constructor return */
return that;
};
/** public functions **********************************************************/
/* interface exported by the module */
module.exports.timeRule = timeRule;
/** private functions *********************************************************/