baja/obj/AbsTime.js

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

/**
 * Defines {@link baja.AbsTime}.
 *
 * @module baja/obj/AbsTime
 */
define([ "bajaScript/sys",
  "bajaScript/baja/obj/Date",
  "bajaScript/baja/obj/RelTime",
  "bajaScript/baja/obj/Simple",
  "bajaScript/baja/obj/Time",
  "bajaScript/baja/obj/TimeFormat",
  "bajaScript/baja/obj/TimeZone",
  "bajaScript/baja/obj/dateTimeUtil",
  "bajaScript/baja/obj/numberUtil",
  "bajaScript/baja/obj/objUtil" ], function (
   baja,
   BDate,
   RelTime,
   Simple,
   Time,
   TimeFormat,
   TimeZone,
   dateTimeUtil,
   numberUtil,
   objUtil) {
  
  "use strict";

  var subclass = baja.subclass,
      callSuper = baja.callSuper,
      strictAllArgs = baja.strictAllArgs,
      strictNumber = baja.strictNumber,
      objectify = baja.objectify,
      bajaHasType = baja.hasType,
      bajaDef = baja.def,
      
      cacheDecode = objUtil.cacheDecode,
      cacheEncode = objUtil.cacheEncode,
      
      addZeroPad = numberUtil.addZeroPad;

  const { SHOW_DATE, SHOW_MILLIS, SHOW_SECONDS, SHOW_TIME, SHOW_ZONE } = TimeFormat;
  const {
    toDateTimeString,
    toDateTimeStringSync,
    toDateTimeStringWithLongYear,
    toNullDateTimeString,
    toNullDateTimeStringSync } = dateTimeUtil;
  
  /**
   * Represents a `baja:AbsTime` in BajaScript.
   * 
   * `AbsTime` encapsulates an absolute point in time relative to a given 
   * time zone.
   * 
   * When creating a `Simple`, always use the `make` method instead of 
   * creating a new Object.
   *
   * @class
   * @alias baja.AbsTime
   * @extends baja.Simple
   */
  var AbsTime = function AbsTime(date, time, offset) {
    // Constructor should be considered private
    callSuper(AbsTime, this, arguments);        
    this.$date = date;
    this.$time = time;
    this.$offset = offset;
  };
  
  subclass(AbsTime, Simple);
  
  /**
   * Make an `AbsTime`.
   * 
   * @param {Object} [obj] the Object literal used for the method's arguments.
   * @param {baja.Date} [obj.date]
   * @param {baja.Time} [obj.time]
   * @param {Number|baja.Simple} [obj.offset] UTC offset in milliseconds (any
   * `baja:Number`). This should be negative for negative time zones, e.g.
   * `-04:00` corresponds to `MILLIS_IN_HOUR * -4`. Additionally, if timeZone is
   * set, this provided offset will be ignored.
   * @param {Date} [obj.jsDate] if defined, this date is used for the date and
   * time. The offset will be read directly from the JS date - the `offset`
   * argument, if provided, will be ignored.
   * @param {baja.TimeZone} [obj.timeZone] the timezone to base this date's
   * offset upon. If obj.jsDate is set, this timezone will be ignored and the
   * jsDate's offset will be used instead.
   * @returns {baja.AbsTime}
   * 
   * @example
   *   // An Object literal is used for the method's arguments...
   *   var at1 = baja.AbsTime.make({
   *     date: baja.Date.make({year: 1981, month: 5, day: 17}),
   *     time: baja.Time.make({hour: 15, min: 30}),
   *     offset: 0
   *   });
   *   
   *   // ...or from a JavaScript Date:
   *   var date = new Date();
   *   var at2 = baja.AbsTime.make({ jsDate: date });
   *   // the offset will be converted from JS Date style to BAbsTime style.
   *   baja.outln(at2.getOffset() === date.getTimezoneOffset() * -60000);
   *   baja.outln(at2.getMillis() === date.getTime());
   */
  AbsTime.make = function (obj) {
    obj = objectify(obj);
    
    var date = bajaDef(obj.date, BDate.DEFAULT),
        time = bajaDef(obj.time, Time.DEFAULT),
        offset = bajaDef(obj.offset, 0),
        jsDate;
        
    if (obj.jsDate !== undefined) {
      // Get information from JavaScript Date
      jsDate = obj.jsDate;
      
      if (!(jsDate instanceof Date)) {
        throw new Error("jsDate must be a JavaScript Date");
      }
      
      date = BDate.make({
        "year": jsDate.getFullYear(), 
        "month": jsDate.getMonth(), 
        "day": jsDate.getDate()
      });
      
      time = baja.Time.make({
        "hour": jsDate.getHours(), 
        "min": jsDate.getMinutes(), 
        "sec": jsDate.getSeconds(), 
        "ms": jsDate.getMilliseconds()
      });
      
      offset = jsDate.getTimezoneOffset() * -60000;
    } else if (obj.timeZone) {
      var targetDate = baja.AbsTime.make({
        date: date,
        time: time,
        offset: obj.timeZone.getUtcOffset()
      });
      if (dateTimeUtil.isDstActive(targetDate.getJsDate(), obj.timeZone)) {
        offset = obj.timeZone.getUtcOffset() + obj.timeZone.getDaylightAdjustment();
      } else {
        offset = obj.timeZone.getUtcOffset();
      }
    }
    
    // The year, month and day must always be specified for this to be valid
    strictAllArgs([ date, time ], [ BDate, Time ]);
    offset = strictNumber(offset);
    
    if (date === BDate.DEFAULT && time === Time.DEFAULT && offset === 0) {
      return AbsTime.DEFAULT;
    }
    
    return new AbsTime(date, time, offset);
  };
  
  /**
   * Make an AbsTime.
   *
   * @param {Object} [obj] the Object Literal used for the method's arguments.
   * @param {baja.Date} [obj.date]
   * @param {baja.Time} [obj.time]
   * @param {Number|baja.Simple} [obj.offset] (any `baja:Number`)
   * @param {Date} [obj.jsDate] if defined, this date is used for the date and time.
   * @returns baja.AbsTime
   * 
   * @example
   *   // An Object Literal is used for the method's arguments...
   *   var at1 = baja.$("baja:AbsTime").make({
   *     date: baja.$("baja:Date").make({year: 1981, month: 5, day: 17}),
   *     time: baja.$("baja:Time").make({hour: 15, min: 30}),
   *     offset: 0
   *   });
   *   
   *   // ...or from a JavaScript Date...
   *   var at2 = baja.$("baja:AbsTime").make({jsDate: new Date()});
   */
  AbsTime.prototype.make = function (obj) {
    return AbsTime.make.apply(AbsTime, arguments); 
  };
  
  /**
   * Decode an `AbsTime` from a String.
   *
   * @method
   * @param {String} str
   * @returns {baja.AbsTime}
   */   
  AbsTime.prototype.decodeFromString = cacheDecode(function (str) {
    // Decode ISO 8601 encoding that BAbsTime creates
    var res = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})\.([0-9]+)(?:(Z)|(?:([-+])([0-9]{2}):([0-9]{2})))$/.exec(str),
        date,
        time,
        offset = 0;
    
    if (res === null) {
      throw new Error("Could not decode AbsTime: " + str);
    }
    
    function parse(s) {
      return parseInt(s, 10);
    }
    
    date = BDate.make({
      "year": parse(res[1]),
      "month": parse(res[2]) - 1, // Zero indexed based
      "day": parse(res[3])
    });
    
    time = Time.make({
      "hour": parse(res[4]),
      "min": parse(res[5]),
      "sec": parse(res[6]),
      "ms": parse(res[7])
    });
        
    if (res[8] !== "Z") {
      // Parse hour and minutes and convert to millis
      offset = parse(res[10]) * (60 * 60 * 1000) + parse(res[11]) * (60 * 1000);
      
      // Apply sign
      if (res[9] === "-") {
        offset *= -1;
      }
    }
    
    return AbsTime.make({
      "date": date, 
      "time": time,
      "offset": offset
    });
  });
  
  /**
   * Encode the AbsTime to a String.
   *
   * @method
   * @returns {String}
   */   
  AbsTime.prototype.encodeToString = cacheEncode(function () {     
    var s = this.$date.encodeToString() + "T" + this.$time.encodeToString(),
        hrOff,
        minOff;
        
    if (this.$offset === 0) {
      s += "Z";
    } else {
      hrOff = Math.floor(Math.abs(this.$offset / (1000 * 60 * 60)));
      minOff = Math.floor(Math.abs((this.$offset % (1000 * 60 * 60)) / (1000 * 60)));

      if (this.$offset < 0) {
        s += "-";
      } else {
        s += "+";
      }

      s += addZeroPad(hrOff, 2) + ":";
      s += addZeroPad(minOff, 2);
    }
    
    return s;
  });
  
  /**
   * Equality test.
   *
   * @param obj
   * @returns {Boolean}
   */
  AbsTime.prototype.equals = function (obj) {  
    if (bajaHasType(obj, 'baja:AbsTime')) {
      return obj.getMillis() === this.getMillis();
    }

    return false;
  };
  
  /**
   * Default AbsTime instance - maps to Java Epoch.
   * @type {baja.AbsTime}
   */
  AbsTime.DEFAULT = new AbsTime(BDate.DEFAULT, Time.DEFAULT, 0);
        
  /**
   * Return the data type symbol.
   *
   * @returns {String}
   */   
  AbsTime.prototype.getDataTypeSymbol = function () {
    return "a";
  };
  
  /**
   * Return the time.
   *
   * @returns {baja.Time}
   */   
  AbsTime.prototype.getTime = function () {
    return this.$time;
  };
  
  /**
   * Return the date.
   *
   * @returns {baja.Date}
   */ 
  AbsTime.prototype.getDate = function () {
    return this.$date;
  };


  /**
   * Returns the day of the week.
   *
   * @see baja.Date#getWeekday
   *
   * @returns {baja.FrozenEnum} a `baja:Weekday` `FrozenEnum`.
   */
  AbsTime.prototype.getWeekday = function () {
    return this.getDate().getWeekday();
  };
  
  /**
   * Return the UTC offset.
   *
   * @returns {Number}
   */ 
  AbsTime.prototype.getOffset = function () {
    return this.$offset;
  };

  /**
   * 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`
   */
  AbsTime.prototype.getMonth = function () {
    return this.getDate().getMonth();
  };

  /**
   * Return the year.
   *
   * @returns {Number}
   */
  AbsTime.prototype.getYear = function () {
    return this.getDate().getYear();
  };

  /**
   * Return the day (1-31).
   *
   * @returns {Number}
   */
  AbsTime.prototype.getDay = function () {
    return this.getDate().getDay();
  };

  /**
   * Return the hour (0-23)
   *
   * @returns {Number}
   */
  AbsTime.prototype.getHour = function () {
    return this.getTime().getHour();
  };

  /**
   * Return minutes (0-59).
   *
   * @returns {Number}
   */
  AbsTime.prototype.getMinute = function () {
    return this.getTime().getMinute();
  };

  /**
   * Return seconds (0-59).
   *
   * @returns {Number}
   */
  AbsTime.prototype.getSecond = function () {
    return this.getTime().getSecond();
  };

  /**
   * Returns milliseconds from the time (0-999)
   *
   * @returns {Number}
   */
  AbsTime.prototype.getMillisecond =  function () {
    return this.getTime().getMillisecond();
  };

  /** 
   * Make an AbsTime with the current date and time.
   *
   * @returns {baja.AbsTime}
   */
  AbsTime.now = function () {
    return AbsTime.make({ jsDate: new Date() });
  };
  
  /**
   * Return a new JavaScript Date based on this AbsTime. The Date will represent
   * the same instant as this AbsTime (as returned by `getMillis()`) but will
   * use the browser's local time zone.
   *
   * @returns {Date}
   */
  AbsTime.prototype.getJsDate = function () {
    // Create a JavaScript Date and return it (warning has no timezone offset)
    return this.$jsDate || (this.$jsDate = new Date(this.getMillis()));
  };

  /**
   * Get the number of milliseconds past the epoch represented by this AbsTime.
   * 
   * @returns {Number}
   */
  AbsTime.prototype.getMillis = function () {
    var millis = this.$millis;
    
    if (millis === undefined) {
      var date = this.$date,
          time = this.$time,
          year = date.getYear(),
          month = date.getMonth().getOrdinal(),
          day = date.getDay(),
          hour = time.getHour(),
          min = time.getMinute(),
          sec = time.getSecond(),
          ms = time.getMillisecond();
      
      millis = this.$millis = 
        Date.UTC(year, month, day, hour, min, sec, ms) - this.$offset;
    }
    
    return millis;
  };

  function doToDateTimeString(toStringFunc, absTime, obj) {
    obj = objectify(obj);
    var textPattern = obj.textPattern || baja.getTimeFormatPattern(),
        show = calculateShow(obj),
        offset = absTime.$offset,
        timezone = obj.TimeZone || obj.timezone,
        millis = absTime.getMillis(),
        isDst = baja.TimeZone.isDstActive(absTime.getJsDate(), timezone);
    if (!(timezone instanceof baja.TimeZone)) {
      if (offset === 0) {
        timezone = TimeZone.UTC;
      } else {
        var offsetDisplayName = "UTC" + dateTimeUtil.formatOffset(offset);

        timezone = baja.TimeZone.make("UTC", offset, 0, null, null, {
          shortDisplayName: offsetDisplayName,
          displayName: offsetDisplayName,
          shortDstDisplayName: offsetDisplayName,
          dstDisplayName: offsetDisplayName
        });
      }
    }

    //if time zone is known, apply offset to AbsTime.$millis
    var millisInTimeZone = millis + timezone.getUtcOffset();
    if (isDst) {
      millisInTimeZone += timezone.getDaylightAdjustment();
    }

    var jsDate = new Date(millisInTimeZone);
    return toStringFunc({
      ok: obj.ok,
      fail: obj.fail,
      show: show,
      textPattern: textPattern,
      year: jsDate.getUTCFullYear(),
      month: baja.$('baja:Month', jsDate.getUTCMonth()),
      day: jsDate.getUTCDate(),
      hour: jsDate.getUTCHours(),
      min: jsDate.getUTCMinutes(),
      sec: jsDate.getUTCSeconds(),
      ms: jsDate.getUTCMilliseconds(),
      timezone: timezone,
      isDst: isDst
    }, obj.lex);
  }

  function calculateShow(obj) {
    if (typeof obj.show === 'number') {
      return obj.show;
    }

    // date, time, and time zone all default to true
    var showDate = obj.showDate !== false,
      showTime = obj.showTime !== false,
      showSeconds = obj.showSeconds,
      showMilliseconds = obj.showMilliseconds,
      showTimeZone = obj.showTimeZone !== false;

    return (showDate ? SHOW_DATE : 0) |
      (showTime ? SHOW_TIME : 0) |
      (showTime && showSeconds ? SHOW_SECONDS : 0) |
      (showTime && showMilliseconds ? SHOW_MILLIS | SHOW_SECONDS : 0) |
      (showTime && showTimeZone ? SHOW_ZONE : 0);
  }

 /**
  * Asynchronously get a `String` representation of the `AbsTime`.
  * 
  * 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 time has been formatted.
  * 
  * @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 {baja.TimeZone} [obj.TimeZone] timezone to use for formatting.
  *                                    If not specified, then the system default
  *                                    timezone will try to be determined,
  *                                    otherwise UTC be used. (As of Niagara
  *                                    4.9, the name "TimeZone" is supported to
  *                                    match `BFacets.TIME_ZONE`. The old key
  *                                    "timezone" is still supported.)
  *
  * @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 formatted string
  * 
  * @example
  *   absTime.toDateTimeString().then(function (dateTimeStr) {
  *     baja.outln("The date time is: " + dateTimeStr);
  *   });
  */
  AbsTime.prototype.toDateTimeString = function (obj) {
    var absTime = this;
    obj = objectify(obj);
    return dateTimeUtil.resolveTimezone(obj).then(function (tz) {
      obj.TimeZone = tz;
      return doToDateTimeString(toDateTimeString, absTime, objectify(obj, "ok"));
    });
  };

  /**
  * Asynchronously get a `String` representation of the `AbsTime` always showing the long year
  * format if year part is available.
  * 
  * 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 {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 {baja.TimeZone} [obj.TimeZone] timezone to use for formatting.
  *                                    If not specified, then the system default
  *                                    timezone will try to be determined,
  *                                    otherwise UTC be used. (As of Niagara
  *                                    4.9, the name "TimeZone" is supported to
  *                                    match `BFacets.TIME_ZONE`. The old key
  *                                    "timezone" is still supported.)
  *
  * @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 formatted string
  * 
  * @since Niagara 4.13
  * 
  * @example
  *   absTime.toDateTimeStringWithLongYear().then(function (dateTimeStr) {
  *     baja.outln("The date time with long year is: " + dateTimeStr);
  *   });
  */
   AbsTime.prototype.toDateTimeStringWithLongYear = function (obj) {
    var absTime = this;
    obj = objectify(obj);
    return dateTimeUtil.resolveTimezone(obj).then(function (tz) {
      obj.TimeZone = tz;
      return doToDateTimeString(toDateTimeStringWithLongYear, absTime, objectify(obj, "ok"));
    });
  };
  
  /**
   * Synchronously get a `String` representation of the `AbsTime`.
   * 
   * 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}
   */
  AbsTime.prototype.toDateTimeStringSync = function (obj) {
    return doToDateTimeString(toDateTimeStringSync, this, obj);
  };

  /**
   * @see .toDateTimeString if obj is defined
   * @see .toDateTimeStringSync if obj is undefined
   * @returns {String|Promise.<String>} a String if an undefined parameter object is passed, otherwise a Promise.<String>
   */
  AbsTime.prototype.toString = function (obj) {
    if (this.isNull()) {
      return toNullString(obj);
    }

    if (obj) {
      return this.toDateTimeString(obj);
    } else {
      return this.toDateTimeStringSync();
    }
  };


  /**
   * Gets the date string for this baja.AbsTime
   * @see baja.Date#toDateString
   * @param {Object} obj the Object Literal for the method's arguments.
   * @returns {Promise.<String>}
   */
  AbsTime.prototype.toDateString = function (obj) {
    if (this.isNull()) {
      return toNullString(obj || {});
    }

    return this.getDate().toDateString(obj);
  };

  /**
   * Gets the time string for this baja.AbsTime
   * @see baja.Time#toTimeString
   * @param {Object} obj the Object Literal for the method's arguments.
   * @returns {Promise.<String>}
   */
  AbsTime.prototype.toTimeString = function (obj) {
    if (this.isNull()) {
      return toNullString(obj  || {});
    }

    return this.getTime().toTimeString(obj);
  };

  /**
   *
   * @param {Object} obj the Object Literal for the method's arguments.
   * @returns {Promise.<String>|String}
   */
  function toNullString(obj) {
    return obj ? toNullDateTimeString(obj) : toNullDateTimeStringSync();
  }

  /**
   * @since Niagara 4.14
   * @returns {boolean} if this represents the null AbsTime.
   */
  AbsTime.prototype.isNull = function () {
    return this.getMillis() === 0;
  };

  /**
   *
   * @returns {Number} result of getMillis
   */
  AbsTime.prototype.valueOf = function () {
    return this.getMillis();
  };

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

  /**
   *
   * @param {number} year
   * @returns {boolean}
   * @since Niagara 4.12
   */
  AbsTime.isLeapYear = function (year) {
    return baja.Date.isLeapYear(year);
  };

  /**
   *
   * @returns {boolean}
   * @since Niagara 4.12
   */
  AbsTime.prototype.isLeapDay = function () {
    return (this.$date.isLeapDay());
  };

  /**
   *
   * @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
   */
  AbsTime.getDaysInMonth = function (year, month) {
    return baja.Date.getDaysInMonth(year, month);
  };

  /**
   * returns a `baja.AbsTime` object that is 1 day after this `baja.AbsTime`
   * @returns {baja.AbsTime}
   * @since Niagara 4.12
   */
  AbsTime.prototype.nextDay = function () {
    return AbsTime.make({
      date: this.$date.nextDay(),
      time: this.getTime(),
      offset: this.getOffset()
    });
  };

  /**
   * returns a `baja.AbsTime` object that is 1 day before this `baja.AbsTime`
   * @returns {baja.AbsTime}
   * @since Niagara 4.12
   */
  AbsTime.prototype.prevDay = function () {
    return AbsTime.make({
      date: this.$date.prevDay(),
      time: this.getTime(),
      offset: this.getOffset()
    });
  };

  /**
   * returns a `baja.AbsTime` object that is 1 month after this `baja.AbsTime`
   * @returns {baja.AbsTime}
   * @since Niagara 4.12
   */
  AbsTime.prototype.nextMonth = function () {
    return AbsTime.make({
      date: this.$date.nextMonth(),
      time: this.getTime(),
      offset: this.getOffset()
    });
  };

  /**
   * returns a `baja.AbsTime` object that is 1 month before this `baja.AbsTime`
   * @returns {baja.AbsTime}
   * @since Niagara 4.12
   */
  AbsTime.prototype.prevMonth = function () { 
    return AbsTime.make({
      date: this.$date.prevMonth(),
      time: this.getTime(),
      offset: this.getOffset()
    });
  };

  /**
   * returns a `baja.AbsTime` object that is 1 year after this `baja.AbsTime`
   * @returns {baja.AbsTime}
   * @since Niagara 4.12
   */
  AbsTime.prototype.nextYear = function () {
    return AbsTime.make({
      date: this.$date.nextYear(),
      time: this.getTime(),
      offset: this.getOffset()
    });
  };

  /**
   * returns a `baja.AbsTime` object that is 1 year before this `baja.AbsTime`
   * @returns {baja.AbsTime}
   * @since Niagara 4.12
   */
  AbsTime.prototype.prevYear = function () {
    return AbsTime.make({
      date: this.$date.prevYear(),
      time: this.getTime(),
      offset: this.getOffset()
    });
  };

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

  return AbsTime;
});