Source: lib/datespan.js

/** @module caltime/datespan
 *
 * @copyright Michael McCarthy <michael.mccarthy@ieee.org> 2017
 * @license MIT
 */

'use strict';

/* dependencies */
const modconst = require('./constants');
const _ = require('lodash');


/* constants */
/* maximum seconds duration */
const MAX_SECS = (59);
/* maximum milliseconds duration */
const MAX_MSECS = (999);

/**
 * Functional constructor which creates an instance of a DateSpan object.
 * Each DateSpan object is immutable.
 * A DateSpan represents a slice of time between a definite begin time
 * and a definite end time. This means that a DateSpan object is anchored
 * to a specific date and time.
 * @param {Date} inBegin Date object representing the starting time of
 * the datespan.
 * @param {Date} [inEnd=null] Date object representing the end time of
 * the datespan.  If null is passed then inDurationMins must be specified. An
 * error is thrown if neither inEnd nor inDurationMins are specified.
 * inEnd must be equal to or after inBegin.
 * @param {number} [inDurationMins=0] Integer representing the duration of the
 * datespan in [minutes]. Must be greater than zero if inEnd is not
 * specified.  An error is thrown if both inEnd and inDurationMins
 * are specified.
 * @param {number} [inDurationSecs=0] Integer representing the [seconds]
 * component of the duration. Value defaults to zero if not specified.
 * An error is thrown if both inEnd and inDurationMins are specified.
 * @param {number} [inDurationMs=0] Integer representing the [milliseconds]
 * component of the duration. Value defaults to zero if not specified.
 * An error is thrown if both inEnd and inDurationMins are specified.
 * @return {@link module:schedtime/datespan} Instance of DateSpan object.
 * @throws {Error}
 */
let dateSpan = function dateSpanFunc(inBegin,
                                      inEnd=null,
                                      inDurationMins=0,
                                      inDurationSecs=0,
                                      inDurationMs=0) {
  /** private *****************************************************************/
  /* new instance of object */
  const that = {};
  /* moment the datespan begins. Must be a Date object. */
  const begin = inBegin;
  /* Number holding the duration of the datespan in [minutes] */
  let durationMins = inDurationMins;
  /* Number holding the [seconds] component of the duration */
  let durationSecs = inDurationSecs;
  /* Number holding the [milliseconds] component of the duration */
  let durationMSecs = inDurationMs;

  if (_.isNil(inBegin)
        || _.isDate(inBegin) === false) {
    throw new Error('Invalid inBegin argument.');
  };
  if (_.isInteger(inDurationMins)
        && (inDurationMins < 0
            || inDurationMins > Number.MAX_SAFE_INTEGER)) {
    throw new Error('Invalid inDurationMins argument.');
  };
  if (_.isInteger(inDurationSecs)
        && (inDurationSecs < 0
            || inDurationSecs > MAX_SECS)) {
    throw new Error('Invalid inDurationSecs argument.');
  };
  if (_.isInteger(inDurationMs)
        && (inDurationMs < 0
            || inDurationMs > MAX_MSECS)) {
    throw new Error('Invalid inDurationMs argument.');
  };
  if (_.isDate(inEnd)
      && (_.toSafeInteger(inDurationMins) > 0
          || _.toSafeInteger(inDurationSecs) > 0
          || _.toSafeInteger(inDurationMs)) > 0) {
    throw new Error('inEnd and a duration cannot be specified together.');
  }
  if (_.isDate(inEnd) === false
      && (_.isInteger(inDurationMins) === false
          || _.isInteger(inDurationSecs) === false
          || _.isInteger(inDurationMs) === false)) {
    throw new Error('inEnd or a duration must be specified.');
  }
  if (_.isDate(inEnd)
    && inEnd.getTime() < inBegin.getTime()) {
    throw new Error('inBegin must be before or the same as inEnd.');
  }
  if (_.isDate(inEnd)) {
    let delta = inEnd.getTime() - inBegin.getTime();
    durationMins = delta / modconst.MSECS_PER_MIN;
    delta = delta - (durationMins * modconst.MSECS_PER_MIN);
    durationSecs = delta / 1000;
    durationMSecs = delta - (durationSecs * 1000);
  }

  /** public methods **********************************************************/

  /**
   * Get the beginning moment of the datespan.
   * @return {Date} Date object representing the inclusive starting moment
   * of the datespan.
   */
  that.getBegin = function getBeginFunc() {
    return begin;
  };

  /**
   * Get the [minutes] component of the duration. The total duration of the
   * DateSpan is the sum of the [minutes], [seconds] and [milliseconds]
   * components of the duration.
   * @return \{number\} Integer with a component of the duration in [minutes].
   */
  that.getDurationMins = function getDurationMinsFunc() {
    return durationMins;
  };

  /**
   * Get the [seconds] component of the duration. The total duration of the
   * DateSpan is the sum of the [minutes], [seconds] and [milliseconds]
   * components of the duration.
   * @return \{number\} Integer with a component of the duration in [seconds].
   */
  that.getDurationSecs = function getDurationSecsFunc() {
    return durationSecs;
  };

  /**
   * Get the [milliseconds] component of the duration. The total duration of the
   * DateSpan is the sum of the [minutes], [seconds] and [milliseconds]
   * components of the duration.
   * @return \{number\} Integer with a component of the duration in [milliseconds].
   */
  that.getDurationMs = function getDurationMsFunc() {
    return durationMSecs;
  };

  /**
   * Get the total duration. The total duration of the
   * DateSpan is the sum of the [minutes], [seconds] and [milliseconds]
   * components of the duration.
   * @return {number} Integer with the duration in [milliseconds].
   */
  that.getTotalDuration = function getTotalDurationFunc() {
    let retval = durationMSecs;
    retval += (durationSecs*1000);
    retval += (durationMins*60*1000);
    return retval;
  };

  /**
   * Get the exclusive end moment of the datespan.
   * @return {Date} Date object which represents the final, non-inclusive
   * moment of the datespan.
   */
  that.getEnd = function getEndFunc() {
    const retval = new Date(begin);
    retval.setUTCMinutes(retval.getUTCMinutes()+durationMins);
    retval.setUTCSeconds(retval.getUTCSeconds()+durationSecs);
    retval.setUTCMilliseconds(retval.getUTCMilliseconds()+durationMSecs);
    return retval;
  };

  /**
   * Create a new DateSpan by adding two spans of time which overlap. If there
   * is no overlap then the method will return null.
   * @param {@link module:schedtime/datespan} inSpan Other DateSpan object which
   * will be added to this datespan.
   * @return {@link module:schedtime/datespan|null} New DateSpan object or null
   * if there was no overlap.
   * @throws {Error}
   */
  that.union = function unionFunc(inSpan) {
    let retval = null;
    if (_.isObject(inSpan) === false) {
      throw new Error('Method expects a valid DateSpan object as argument.');
    } else if (that.isIntersect(inSpan)) {
      const newBegin = (begin.getTime() <= inSpan.getBegin().getTime()? begin: inSpan.getBegin());
      const newEnd = (that.getEnd().getTime() >= inSpan.getEnd().getTime()? that.getEnd(): inSpan.getEnd());
      retval = dateSpan(newBegin, newEnd);
    }
    return retval;
  };

  /**
   * Subtract one datespan from another.  The datespan, upon which the method
   * is being called, must overlap completely with the subtracted datespan.
   * null is returned if this datespan does not overlap sufficiently.
   * @param {@link module:schedtime/datespan} inSpan Other DateSpan object
   * which will be subtracted from this datespan.
   * @return {Array|null} Array containing one or two new DateSpan objects.
   * These are the remainders after the subtraction. null is returned if there
   * wasn't sufficient overlap of the date-spans to complete the subtraction.
   * @throws Error
   */
  that.subtract = function subtractFunc(inSpan) {
    let retval = [];
    if (_.isObject(inSpan)
        && that.isIntersect(inSpan)
        && that.getBegin().getTime() <= inSpan.getBegin().getTime()
        && that.getEnd().getTime() >= inSpan.getEnd().getTime()) {
          // datespans start at the same time, only one remainder datespan.
          if (that.getBegin().getTime() === inSpan.getBegin().getTime()
              && that.getEnd().getTime() > inSpan.getEnd().getTime()) {
            const newBegin = inSpan.getEnd();
            const newEnd = that.getEnd();
            retval.push(dateSpan(newBegin, newEnd));
          // datespans end at the same time, only on remainder datespan.
          } else if (that.getEnd().getTime() === inSpan.getEnd().getTime()
                    && that.getBegin().getTime() < inSpan.getBegin().getTime()) {
            const newBegin = that.getBegin();
            const newEnd = inSpan.getBegin();
            retval.push(dateSpan(newBegin, newEnd));
          // two remainder datespans.
          } else if (that.getBegin().getTime() < inSpan.getBegin().getTime()
                    && that.getEnd().getTime() > inSpan.getEnd().getTime()) {
            // first remainder
            let newBegin = that.getBegin();
            let newEnd = inSpan.getBegin();
            retval.push(dateSpan(newBegin, newEnd));
            // second remainder
            newBegin = inSpan.getEnd();
            newEnd = that.getEnd();
            retval.push(dateSpan(newBegin, newEnd));
          }
    } else if (_.isObject(inSpan) === false) {
      throw new Error('Invalid argument passed as argument to method.');
    } else if (that.getBegin().getTime() > inSpan.getBegin().getTime()
            || that.getEnd().getTime() < inSpan.getEnd().getTime()) {
      // insufficient overlap
      retval = null;
    }
    return retval;
  };

  /**
   * Create a new DateSpan by finding the intersection between two datespans. If
   * there is no intersection/overlap then the method will return null.
   * @param {@link module:schedtime/datespan} inSpan Other DateSpan object
   * which will be 'intersected' with this datespan.
   * @return {@link module:schedtime/datespan|null} New DateSpan object or null
   * if there was no overlap.
   */
  that.intersect = function intersectFunc(inSpan) {
    let retval = null;
    if (_.isObject(inSpan) === false) {
      throw new Error('Method expects a valid DateSpan object as argument.');
    } else if (that.isIntersect(inSpan)) {
      const newBegin = (begin.getTime() <= inSpan.getBegin().getTime()? inSpan.getBegin(): begin);
      const newEnd = (that.getEnd().getTime() >= inSpan.getEnd().getTime()? inSpan.getEnd(): that.getEnd());
      retval = dateSpan(newBegin, newEnd);
    }
    return retval;
  };

  /**
   * Check if two date-spans are intersecting. As end times are exclusive i.e.
   * not part of the duration, there is no intersection if the end time of
   * one date-span is equal to the begin time of another date-span.
   * @param {@link module:schedtime/datespan} inSpan DateSpan object. Error
   * thrown if not an object.
  * @return {boolean} True indicates there is an overlap/intersection, otherwise
  * false is returned.
  */
  that.isIntersect = function isIntersectFunc(inSpan) {
    let retval = true;
    if (_.isObject(inSpan) === false) {
      throw new Error('Method expects DateSpan object as argument.');
    }
    if (that.getEnd().getTime() <= inSpan.getBegin().getTime()
        || that.getBegin().getTime() >= inSpan.getEnd().getTime()) {
          retval = false;
    }
    return retval;
  };

  /**
   * Convert the state of the DateSpan object to a string. Method is only
   * intended for debugging purposes. The format of the string will change
   * in future releases.
   * @return {string} String holding the state of the date-span.
   */
  that.toString = function toStringFunc() {

    // TODO finalise the format of the string
    let retval = `[[begin: ${begin.toISOString()}]`;
    retval = retval + `[duration: ${durationMins}m ${durationSecs}s `;
    retval = retval + `${durationMSecs}ms]]`;
    return retval;
  }

  /* functional constructor returns new instance */
  return that;
};


/** public functions **********************************************************/


/**
 * Merge multiple, intersecting and non-intersecting date-spans within an array.
 * @param {Array} inSpans Array of date-spans. All of the date-spans do
 * not have to be intersecting but those which are will be merged together using
 * a union operation. An Error is thrown if an array is not passed as
 * the argument.
 * @return {Array} Array containing the merged and unmerged date-spans.
 * @throws {Error}
 */
let mergeSpans = function mergeSpansFunc(inSpans) {
  const retval = [];
  let newBegin = null;
  let newEnd = null;
  if(_.isArray(inSpans) === false) {
    throw new Error('Function expects an array as argument.');
  }
  const sorted = _.sortBy(inSpans,
                          function(obj) {
                            return obj.getBegin;
                          });

  for (let i=0; i<sorted.length; i++) {
    let currentSpan = sorted[i];
    /* do we have enough to make a datespan after last iteration. only create
       where previous datespan does not intersect with current. */
    if (newBegin !== null
        && newEnd !== null
        && newEnd.getTime() < currentSpan.getBegin().getTime()) {
      let newSpan = dateSpan(newBegin, newEnd);
      retval.push(newSpan);
      newBegin = null;
      newEnd = null;
    }
    if (newBegin === null) {
      newBegin = currentSpan.getBegin();
    }
    if (newEnd === null
        || newEnd.getTime() < currentSpan.getEnd().getTime()) {
      newEnd = currentSpan.getEnd();
    }
  }
  if (newBegin !== null
      && newEnd != null) {
    let newSpan = dateSpan(newBegin, newEnd);
    retval.push(newSpan);
  }
  return retval;
};

/**
 * Sort multiple date-spans within an array by their start time.
 * @param {Array} inSpans Array of date-spans.
 * An Error is thrown if an array is not passed as the argument.
 * @param {boolean} [inIsDescending=false] true if date-spans should be sorted
 * in descending order. The default is true which means the date-spans should
 * be sorted in ascending order.
 * @return {Array} Array containing the sorted date-spans.
 * @throws {Error}
 */
let sortSpans = function sortSpansFunc(inSpans, inIsDescending=false) {
  let retval = null;
  let order = 'asc';
  if(_.isArray(inSpans) === false) {
    throw new Error('Function expects an array as argument.');
  }
  if(_.isBoolean(inIsDescending)
      && inIsDescending) {
    order = 'desc';
  }
  retval = _.orderBy(inSpans,
                          [ function(obj) {
                              return obj.getBegin().getTime();
                            } ],
                          [ order ]);
  return retval;
};


/* interface exported by the module */
module.exports.dateSpan = dateSpan;
module.exports.mergeSpans = mergeSpans;
module.exports.sortSpans = sortSpans;

/** private functions *********************************************************/