baja/obj/Date.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/**
 * Defines {@link baja.Date}.
 * @module baja/obj/Date
 */
define([ "bajaScript/sys",
        "bajaScript/baja/obj/Simple",
        "bajaScript/baja/obj/TimeFormat",
        "bajaScript/baja/obj/dateTimeUtil",
        "bajaScript/baja/obj/numberUtil",
        "bajaScript/baja/obj/objUtil" ], function (
         baja, 
         Simple, 
         TimeFormat, 
         dateTimeUtil, 
         numberUtil, 
         objUtil) {
  
  "use strict";
  
  var subclass = baja.subclass,
      callSuper = baja.callSuper,
      strictArg = baja.strictArg,
      strictNumber = baja.strictNumber,
      objectify = baja.objectify,
      bajaHasType = baja.hasType,
      
      cacheDecode = objUtil.cacheDecode,
      cacheEncode = objUtil.cacheEncode,
      
      SHOW_DATE = TimeFormat.SHOW_DATE,
      SHOW_MILLIS = TimeFormat.SHOW_MILLIS,
      SHOW_SECONDS = TimeFormat.SHOW_SECONDS,
      SHOW_TIME = TimeFormat.SHOW_TIME,
      
      toDateTimeString = dateTimeUtil.toDateTimeString,
      toDateTimeStringSync = dateTimeUtil.toDateTimeStringSync,
      addZeroPad = numberUtil.addZeroPad;
  
  /**
   * Represents a `baja:Date` in BajaScript.
   * 
   * `Date` represents a specific day, month, and year.
   * 
   * When creating a `Simple`, always use the `make()` method instead of 
   * creating a new Object.
   *
   * @class
   * @alias baja.Date
   * @extends baja.Simple
   */
  var BDate = function BDate(year, month, day) {
    // Constructor should be considered private
    callSuper(BDate, this, arguments); 
    this.$year = year;
    this.$month = month; // Zero indexed
    this.$day = day;       
  };
  
  subclass(BDate, Simple);
  
  /**
   * Make a `Date`.
   * 
   * @param {Object} obj - the Object Literal.
   *
   * @param {Number|baja.Simple} obj.year (any `baja:Number` type)
   *
   * @param {Number|baja.Simple|baja.FrozenEnum} obj.month - (any `baja:Number`
   * type) (0-11) or a `baja:Month` `FrozenEnum` for the month of the year.
   *
   * @param {Number|baja.Simple} obj.day - (1-31). (any `baja:Number` type)
   *
   * @param {Date} [obj.jsDate] - A JavaScript `Date` used to specify the year,
   * month and day. If defined, this will override the year, month and day
   * arguments.
   * 
   * @returns {baja.Date}
   * 
   * @example
   *   //An Object Literal is used for the method's arguments...
   *   var d1 = baja.Date.make({
   *     year: 2008,
   *     month: baja.$("baja:Month").get("december"),
   *     day: 24
   *   });
   *   
   *   // ...or from a JavaScript Date...
   *   var d2 = baja.Date.make({
   *     jsDate: date
   *   });
   */
  BDate.make = function (obj) {
    obj = objectify(obj);
    
    var year,
        month,
        day,
        d;
    
    // Create baja.Date from a JavaScript date
    if (obj.jsDate && obj.jsDate instanceof Date) {
      year = obj.jsDate.getFullYear();
      month = obj.jsDate.getMonth(); // zero index based
      day = obj.jsDate.getDate();
    } else {
      year = obj.year;
      month = obj.month; // If a number, this should be zero index based
      day = obj.day;
      
      // If the month is a baja:Month then get its ordinal as zero index based
      if (bajaHasType(month) && month.getType().toString() === "baja:Month") {
        month = month.getOrdinal();
      }
    }
       
    // Validate we have these specified
    year = strictNumber(year);
    month = strictNumber(month);
    day = strictNumber(day);
    
    if (year < 0 || month < 0 || month > 11 || day < 1 || day > 31) {
      throw new Error("Invalid date range");
    }
    
    // Check to see if we should return the default instance
    d = BDate.DEFAULT;
    if (year === d.$year && month === d.$month && day === d.$day) {
      return d;
    }
    
    return new BDate(year, month, day);
  };
  
  /**
   * Make a Date.
   *
   * @param {Object} obj - the Object Literal.
   *
   * @param {Number|baja.Simple} [obj.year] (any `baja:Number` type)
   *
   * @param {Number|baja.Simple|baja.FrozenEnum} [obj.month] - (any `baja:Number`
   * type) (0-11) or a `baja:Month` `FrozenEnum` for the month of the year.
   *
   * @param {Number|baja.Simple} [obj.day] - (1-31). (any `baja:Number` type)
   *
   * @param {Date} [obj.jsDate] A JavaScript `Date` used to specify the year,
   * month and day. If defined, this will override the year, month and day
   * arguments.
   *
   * @returns {baja.Date}
   * 
   * @example
   *   // An Object Literal is used to for the method's arguments...
   *   var d1 = baja.$("baja:Date").make({
   *     year: 2008,
   *     month: baja.$("baja:Month").get("december"),
   *     day: 24
   *   });
   *   // ...or from a JavaScript Date...
   *   var d2 = baja.$("baja:Date").make({
   *     jsDate: date
   *   });
   */
  BDate.prototype.make = function (obj) {
    return BDate.make.apply(BDate, arguments);
  };
  
  /**
   * Decode a `Date` from a `String`. Expects ISO 8601 encoding (`yyyy-mm-dd`).
   * 
   * @method
   *
   * @param {String} str
   *
   * @returns {baja.Date}
   */   
  BDate.prototype.decodeFromString = cacheDecode(function (str) {
    // Decode ISO 8601 encoding yyyy-mm-dd
    var res = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})$/.exec(str),
        d;
    
    if (res === null) {
      throw new Error("Could not decode baja.Date: " + str);
    }
    
    function parse(s) {
      return parseInt(s, 10);
    }
    
    d = BDate.make({
      year: parse(res[1]), 
      month: parse(res[2]) - 1, 
      day: parse(res[3])
    });
    
    return d;
  });
  
  /**
   * Encode the `Date` to a String. Uses ISO 8601 encoding (`yyyy-mm-dd`).
   * 
   * @method
   *
   * @returns {String}
   */ 
  BDate.prototype.encodeToString = cacheEncode(function () {    
    var s = addZeroPad(this.$year, 4) + "-" + 
            addZeroPad((this.$month + 1), 2) + "-" + 
            addZeroPad(this.$day, 2);
    return s;
  });
  
  function dateCompareTo(date1, date2) {
    strictArg(date2, BDate);
    if (date1.$year !== date2.$year) { 
      return date1.$year - date2.$year;
    }      
    if (date1.$month !== date2.$month) {
      return date1.$month - date2.$month;
    }
    if (date1.$day !== date2.$day) {
      return date1.$day - date2.$day;
    }
    return 0;
  }
  
  /**
   * Equality test.
   *
   * @param obj
   *
   * @returns {Boolean}
   */ 
  BDate.prototype.equals = function (obj) {
    if (bajaHasType(obj) && obj.getType().equals(this.getType())) {
      return dateCompareTo(this, obj) === 0;
    }

    return false;
  };
  
  /**
   * Default Date instance.
   * @type {baja.Date}
   */
  BDate.DEFAULT = new BDate(1970, 0, 1);
        
  /**
   * Return a `Date` that maps to the current day.
   *
   * @returns {baja.Date}
   */
  BDate.today = function () {
    return BDate.make({ jsDate: new Date() });
  };
  
  /**
   * Return the year.
   *
   * @returns {Number}
   */
  BDate.prototype.getYear = function () {
    return this.$year;
  };
  
  /**
   * Return the month.
   * 
   * When invoking this method, please ensure the `baja:Month` Type has been 
   * imported.
   *
   * @see baja.importTypes
   *
   * @returns {baja.FrozenEnum} a `baja:Month` `FrozenEnum`
   */
  BDate.prototype.getMonth = function () {
    return baja.$("baja:Month").get(this.$month);
  };
  
  /**
   * Return the day (1-31).
   *
   * @returns {Number}
   */
  BDate.prototype.getDay = function () {
    return this.$day;
  };
  
  /**
   * Return a new JavaScript `Date` using this date's year, month and day.
   *
   * @returns {Date}
   */   
  BDate.prototype.getJsDate = function () {
    // JavaScript Date is mutable therefore we have to return a new instance of
    // Date each time
    return new Date(this.$year, 
                    this.$month, 
                    this.$day, 
                    /*hours*/0, 
                    /*minutes*/0, 
                    /*seconds*/0, 
                    /*ms*/0);
  };
  
  function getCachedJsDate(date) {
    // Lazily create and return an immutable cached version of the JavaScript
    // Date
    if (date.$jsDate === undefined) {
      date.$jsDate = date.getJsDate();
    }
    return date.$jsDate;
  }
  
  /**
   * Return the weekday as a `baja:Weekday` `FrozenEnum`.
   * 
   * When invoking this method, please ensure the `baja:Weekday` Type has been
   * imported.
   * 
   * @see baja.importTypes
   *
   * @returns {baja.FrozenEnum} a `baja:Weekday` `FrozenEnum`.
   */
  BDate.prototype.getWeekday = function () {
    return baja.$("baja:Weekday").get(getCachedJsDate(this).getDay());
  };
  
 /**
  * Return true if the specified date is before this date.
  * 
  * @param {baja.Date} date.
  *
  * @returns {Boolean}
  */
  BDate.prototype.isBefore = function (date) {
    return dateCompareTo(this, date) < 0;
  };
  
 /**
  * Return true if the specified date is after this date.
  * 
  * @param {baja.Date} date.
  *
  * @returns {Boolean}
  */
  BDate.prototype.isAfter = function (date) {
    return dateCompareTo(this, date) > 0;
  };
  
  function doToDateTimeString(toStringFunc, date, obj) {
    obj = objectify(obj, "ok");
  
    var textPattern = obj.textPattern || baja.getTimeFormatPattern(),
        show = (obj.show || 0) | SHOW_DATE;
  
    // Filter out invalid flags
    show &= ~SHOW_TIME;
    show &= ~SHOW_SECONDS;
    show &= ~SHOW_MILLIS;
  
    return toStringFunc({
      ok: obj.ok,
      fail: obj.fail,
      show: show,
      textPattern: textPattern,
      year: date.getYear(),
      month: date.getMonth(),
      day: date.getDay()
    }, obj.lex);
  }
      
 /**
  * Asynchronously get a String representation of the `Date`.
  * 
  * This method is invoked asynchronously. A Function callback or an Object 
  * Literal that contains a Function callback must be supplied.
  *
  * @param {Object} [obj] - the Object Literal for the method's arguments.
  *
  * @param {Function} [obj.ok] - (Deprecated: use Promise) the Function callback
  * that will be invoked once the date has been formatted into a String.
  *
  * @param {Function} [obj.fail] - (Deprecated: use Promise) the Function
  * callback that will be invoked if a fatal error occurs whilst formatting the
  * String.
  *
  * @param {String} [obj.textPattern] - the text pattern to use for formatting.
  * If not specified, then the user's default time format text pattern will be
  * used.
  *
  * @param {Number} [obj.show] - flags used to format the time. For more
  * information, please see {@link baja.TimeFormat}.
  *                            
  * @returns {Promise.<String>} promise to be resolved with the date string
  * 
  * @example
  *   myDate.toDateString().then(function (dateStr) {
  *     baja.outln("The date is: " + dateStr);
  *   });
  */
  BDate.prototype.toDateString = function (obj) {
    return doToDateTimeString(toDateTimeString, this, objectify(obj, "ok"));
  };
  
  /**
   * Synchronously get a `String` representation of the `Date`.
   * 
   * This method is invoked synchronously. The string result will be returned
   * directly from this function.
   *
   * **Notes on lexicons:**
   *
   * * A lexicon will be used if it is passed in.
   * * If no lexicon is passed in, the baja lexicon will be used if it has been
   * cached locally.
   * * If the baja lexicon has not been cached, strings units will be
   * represented by their internal tag names (which are in English).
   *
   * @param {Object} obj - the Object Literal for the method's arguments.
   *
   * @param {String} [obj.textPattern] - the text pattern to use for formatting.
   * If not specified, then the user's default time format text pattern will be
   * used.
   *
   * @param {Number} [obj.show] - flags used to format the time. For more
   * information, please see {@link baja.TimeFormat}.
   *                            
   * @param [obj.lex] the `baja` lexicon
   * 
   * @returns {String}
   */
  BDate.prototype.toDateStringSync = function (obj) {
    return doToDateTimeString(toDateTimeStringSync, this, obj);
  };

  /**
   * @see .toDateStringSync
   */
  BDate.prototype.toString = function (obj) {
    return this.toDateStringSync(obj);
  };

  /**
   * @returns {Number} a number that can be compared for sorting (same as JSDate.valeuOf)
   */
  BDate.prototype.valueOf = function () {
    return this.getJsDate().valueOf();
  };

  //region prev and next - day, month, year functions
  /**
   *
   * @param {number} year
   * @returns {boolean}
   * @since Niagara 4.12
   */
  BDate.isLeapYear = function (year) {
    return ((year >= 1582) && (year % 4 === 0) && (year % 100 !== 0 || year % 400 === 0));
  };

  /**
   *
   * @returns {boolean}
   * @since Niagara 4.12
   */
  BDate.prototype.isLeapDay = function () {
    return ((this.getMonth().$ordinal === 1) && (this.getDay() === 29));
  };

  /**
   *
   * @param {number} year
   * @param {number/baja.FrozenEnum} month either a `baja:Month` `FrozenEnum` or an integer (January = 0)
   * @returns {number} days in month
   * @since Niagara 4.12
   */
  BDate.getDaysInMonth = function (year, month) {
    const daysInMonth = [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];

    // If the month is a baja:Month then get its ordinal as zero index based
    if (bajaHasType(month, "baja:Month")) {
      month = month.getOrdinal();
    }

    if (month === 1) {
      return this.isLeapYear(year) ? 29 : 28;
    } else {
      return daysInMonth[month];
    }
  };

  /**
   * returns a `baja.Date` object that is 1 day after this `baja.Date`
   * @returns {baja.Date}
   * @since Niagara 4.12
   */
  BDate.prototype.nextDay = function () {
    let year = this.getYear(),
      month = this.getMonth().$ordinal,
      day = this.getDay();

    day++;

    if (day > baja.Date.getDaysInMonth(year, month)) {
      day = 1;
      month++;
    }

    if (month > 11) {
      month = 0;
      year++;
    }

    return baja.Date.make({ year, month, day });
  };

  /**
   * returns a `baja.Date` object that is 1 day before this `baja.Date`
   * @returns {baja.Date}
   * @since Niagara 4.12
   */
  BDate.prototype.prevDay = function () {
    let year = this.getYear(),
      month = this.getMonth().$ordinal,
      day = this.getDay();

    day--;

    if (day < 1) {
      month--;

      if (month < 0) {
        month = 11;
        year--;
      }

      day = baja.Date.getDaysInMonth(year, month);
    }


    return baja.Date.make({ year, month, day });
  };

  /**
   * returns a `baja.Date` object that is 1 month after this `baja.Date`
   * @returns {baja.Date}
   * @since Niagara 4.12
   */
  BDate.prototype.nextMonth = function () {
    let year = this.getYear(),
        month = this.getMonth().$ordinal,
        day = this.getDay();

    if (month === 11) {
      month = 0;
      year++;
    } else if (day === baja.Date.getDaysInMonth(year, month)) {
      month++;
      day = baja.Date.getDaysInMonth(year, month);
    } else {
      month++;
      if (day > baja.Date.getDaysInMonth(year, month)) {
        day = baja.Date.getDaysInMonth(year, month);
      }
    }

    return baja.Date.make({ year, month, day });
  };

  /**
   * returns a `baja.Date` object that is 1 month before this `baja.Date`
   * @returns {baja.Date}
   * @since Niagara 4.12
   */
  BDate.prototype.prevMonth = function () {
    let year = this.getYear(),
        month = this.getMonth().$ordinal,
        day = this.getDay();

    if (month === 0) {
      month = 11;
      year--;
    } else if (day === baja.Date.getDaysInMonth(year, month)) {
      month--;
      day = baja.Date.getDaysInMonth(year, month);
    } else {
      month--;
      if (day > baja.Date.getDaysInMonth(year, month)) {
        day = baja.Date.getDaysInMonth(year, month);
      }
    }

    return baja.Date.make({ year, month, day });
  };

  /**
   * returns a `baja.Date` object that is 1 year after this `baja.Date`
   * @returns {baja.Date}
   * @since Niagara 4.12
   */
  BDate.prototype.nextYear = function () {
    let year = this.getYear(),
      month = this.getMonth().$ordinal,
      day = this.getDay();

    year++;

    if (this.isLeapDay()) {
      day = 28;
    }

    return baja.Date.make({ year, month, day });
  };

  /**
   * returns a `baja.Date` object that is 1 year before this `baja.Date`
   * @returns {baja.Date}
   * @since Niagara 4.12
   */
  BDate.prototype.prevYear = function () {
    let year = this.getYear(),
      month = this.getMonth().$ordinal,
      day = this.getDay();

    year--;

    if (this.isLeapDay()) {
      day = 28;
    }

    return baja.Date.make({ year, month, day });
  };

  //endregion prev and next - day, month, year functions

  return BDate;
});