baja/obj/dateTimeUtil.js

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

/*eslint-env browser */

define([ "bajaScript/sys",
        "bajaScript/baja/obj/TimeFormat",
        "bajaPromises",
        "lex!"
  ], function dateTimeUtil(
         baja,
         TimeFormat,
         Promise,
         lexjs) {

  "use strict";

  // In the first version, this implementation is currently limited
  var patternCache = {},
  
      SHOW_DATE = TimeFormat.SHOW_DATE,
      SHOW_TIME = TimeFormat.SHOW_TIME,
      SHOW_SECONDS = TimeFormat.SHOW_SECONDS,
      SHOW_MILLIS = TimeFormat.SHOW_MILLIS,
      SHOW_ZONE = TimeFormat.SHOW_ZONE,
      SHOW = [
        0,            // 0  blank
        SHOW_DATE,    // 1  YEAR_2
        SHOW_DATE,    // 2  YEAR_4
        SHOW_DATE,    // 3  MON_1
        SHOW_DATE,    // 4  MON_2
        SHOW_DATE,    // 5  MON_TAG
        SHOW_DATE,    // 6  DAY_1
        SHOW_DATE,    // 7  DAY_2
        SHOW_TIME,    // 8  HOUR_12_1
        SHOW_TIME,    // 9  HOUR_12_2
        SHOW_TIME,    // 10 HOUR_24_1
        SHOW_TIME,    // 11 HOUR_24_2
        SHOW_TIME,    // 12 MIN
        SHOW_TIME,    // 13 AM_PM
        SHOW_SECONDS | SHOW_MILLIS, // 14 SEC
        SHOW_ZONE,    // 15 ZONE_TAG
        SHOW_DATE,    // 16  WEEK_1
        SHOW_DATE,    // 17  WEEK_2
        SHOW_DATE,    // 18  MON
        SHOW_ZONE,    // 19  ZONE_OFFSET
        SHOW_DATE     // 20  WEEK_YEAR
      ],
      YEAR_2    = 1,   // YY   two digit year
      YEAR_4    = 2,   // YYYY four digit year
      MON_1     = 3,   // M    one digit month
      MON_2     = 4,   // MM   two digit month
      MON_TAG   = 5,   // MMM  short tag month
      MON       = 18,  // MMMM  long tag month
      DAY_1     = 6,   // D    one digit day of month
      DAY_2     = 7,   // DD   two digit day of month
      HOUR_12_1 = 8,   // h    one digit 12 hour
      HOUR_12_2 = 9,   // hh   two digit 12 hour
      HOUR_24_1 = 10,  // H    one digit 24 hour
      HOUR_24_2 = 11,  // HH   two digit 24 hour
      MIN       = 12,  // mm   two digit minutes
      AM_PM     = 13,  // a    AM PM marker
      SEC       = 14,  // ss   two digit seconds and millis
      ZONE_TAG  = 15,  // z    timezone
      WEEK_1    = 16,  // W    short tag day of week
      WEEK_2    = 17,  // WW   day of week
      ZONE_OFFSET = 19, // Z    timezone offset (RFC 822)
      WEEK_YEAR = 20,  // w    week of year

      dateFieldRegex = /MMMM|MMM|MM|M|DD|D|WW|W|w|YYYY|YY/,
      dateFieldAndSeparatorRegex = new RegExp('([^a-zA-Z]*)(' +
                                              dateFieldRegex.source + ')'),
      timeFieldRegex = /HH|H|hh|h|mm|ss|a|Z|z/,
      timeFieldAndSeparatorRegex = new RegExp('([^a-zA-Z]*)(' +
                                              timeFieldRegex.source + ')'),
      hasBeenWarnedTimeZoneNotSupported = false,
      objectify = baja.objectify;

////////////////////////////////////////////////////////////////
// Support functions
////////////////////////////////////////////////////////////////

  function toCode(c, count) {
    switch (c) {
      case "Y": return count <= 2 ? YEAR_2 : YEAR_4;
      case "M":
        switch (count) {
          case 1: return MON_1;
          case 2: return MON_2;
          case 3: return MON_TAG;
        }
        return MON;
      case "D": return count === 1 ? DAY_1 : DAY_2;
      case "h": return count === 1 ? HOUR_12_1 : HOUR_12_2;
      case "H": return count === 1 ? HOUR_24_1 : HOUR_24_2;
      case "m": return MIN;
      case "s": return SEC;
      case "a": return AM_PM;
      case "z": return ZONE_TAG;
      case "Z": return ZONE_OFFSET;
      case "W": return count === 1 ? WEEK_1 : WEEK_2;
      case "w": return WEEK_YEAR;
      default:  return c.charCodeAt(0);
    }
  }

  function buildPattern(textPattern) {
    // Allocate a pattern array
    var len = textPattern.length,
        pattern = [],
        last = textPattern.charAt(0),
        count = 1,
        i,
        c;

    // Parse text pattern into pattern codes
    for (i = 1; i < len; ++i) {
      c = textPattern.charAt(i);
      if (last === c) {
        count++;
        continue;
      }
      pattern.push(toCode(last, count));
      last = c;
      count = 1;
    }
    pattern.push(toCode(last, count));
    return pattern;
  }

  function pad(s, num) {
    if (num < 10) {
      s += "0";
    }
    s += num;
    return s;
  }

  function siftTimeFormat(timeFormat) {
    var sifted = { timeOnly: '', dateOnly: '' },
        dateMatch,
        timeMatch;


    while (timeFormat) {

      // Find the first date and time fields remaining in timeFormat.
      // Include any preceding non-alphanumeric characters.
      dateMatch = dateFieldAndSeparatorRegex.exec(timeFormat);
      timeMatch = timeFieldAndSeparatorRegex.exec(timeFormat);

      // If there is a match for both, extract the earlier one.
      if ((dateMatch && timeMatch && dateMatch.index < timeMatch.index) ||
        (dateMatch && !timeMatch)) {

        sifted.dateOnly += dateMatch[0];

        // Remove that dateField and all preceding text from timeFormat.
        timeFormat = timeFormat.substring(dateMatch.index +
                                          dateMatch[0].length);

      } else if ((timeMatch && dateMatch && timeMatch.index < dateMatch.index) ||
        (timeMatch && !dateMatch)) {

        sifted.timeOnly += timeMatch[0];

        // Remove that timeField and all preceding text from timeFormat.
        timeFormat = timeFormat.substring(timeMatch.index +
                                          timeMatch[0].length);

      } else {
       timeFormat = '';
      }
    }
    return sifted;
  }

////////////////////////////////////////////////////////////////
// Exports
////////////////////////////////////////////////////////////////

  /**
   * Utilities for working with numbers.
   *
   * @private
   * @exports bajaScript/baja/obj/dateTimeUtil
   */
  var exports = {};

  exports.MILLIS_IN_SECOND = 1000;
  exports.MILLIS_IN_MINUTE = exports.MILLIS_IN_SECOND * 60;
  exports.MILLIS_IN_HOUR = exports.MILLIS_IN_MINUTE * 60;
  exports.MILLIS_IN_DAY = exports.MILLIS_IN_HOUR * 24;

  exports.DEFAULT_TIME_FORMAT = 'D-MMM-YY h:mm:ss a z';

  /*
   * return a new RegExp that matches a date field format.
   *
   * @private
   * @returns {RegExp}
   */
  exports.$dateFieldRegex = function () {
    return new RegExp(dateFieldRegex.source);
  };

  /*
   * return a new RegExp that matches a date field format as well as any
   * preceding non-alphabetic characters.
   *
   * group[0]: full match
   * group[1]: only the preceding non-alphabetic separator characters
   * group[2]: only the date field format
   *
   * @private
   * @returns {RegExp}
   */
  exports.$dateFieldAndSeparatorRegex = function () {
    return new RegExp(dateFieldAndSeparatorRegex.source);
  };

  /*
   * return a new RegExp that matches a time field format.
   *
   * @private
   * @returns {RegExp}
   */
  exports.$timeFieldRegex = function () {
    return new RegExp(timeFieldRegex.source);
  };

  /*
   * return a new RegExp that matches a time field format as well as any
   * preceding non-alphabetic characters.
   *
   * group[0]: full match
   * group[1]: only the preceding non-alphabetic separator characters
   * group[2]: only the time field format
   *
   * @private
   * @returns {RegExp}
   */
  exports.$timeFieldAndSeparatorRegex = function () {
    return new RegExp(timeFieldAndSeparatorRegex.source);
  };

  var getCachedOffsetJanuary = (function () {
    var cachedOffsets = {},
      JANUARY = new Date("01/01/2019");
    return function (timezone) {
      var cache = cachedOffsets[timezone.getId()];
      if (cache !== undefined) { return cache; }

      var offset = exports.getUtcOffsetInTimeZone(JANUARY, timezone);
      cachedOffsets[timezone.getId()] = offset;
      return offset;
    };
  }());

  var getCachedOffsetJune = (function () {
    var cachedOffsets = {},
      JUNE = new Date("06/01/2019");
    return function (timezone) {
      var cache = cachedOffsets[timezone.getId()];
      if (cache !== undefined) { return cache; }

      var offset = exports.getUtcOffsetInTimeZone(JUNE, timezone);
      cachedOffsets[timezone.getId()] = offset;
      return offset;
    };
  }());


  /**
   * Return true if daylight savings time is active for the given date.
   * Note: If no timezone is provided, this will not use timezone rules, rather
   * a simplistic method of comparing offsets in January and June for the local
   * browser.
   *
   * If a timezone is provided, daylights savings will be determined at the
   * given date in that timezone.
   *
   * Please note: javascript dates will always have a local timezone
   * offset. The underlying milliseconds from epoch value will be used as the
   * source of truth for the time. This point in time will then be used to
   * determine whether daylight savings is active at that underlying millisecond
   * value in the given timezone (if provided).
   *
   * @param {Date} [d] the date to check. If omitted, the current date will be
   * checked.
   * @param {baja.TimeZone} [timeZone] the timezone to use.
   * @returns {boolean} true if daylight savings time is active
   */
  exports.isDstActive = function (d, timeZone) {
    d = d || new Date();
    if (!timeZone) {
      var jan = new Date(d.getFullYear(), 0, 1),
        jul = new Date(d.getFullYear(), 6, 1),
        stdTimezoneOffset = Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset());
      return d.getTimezoneOffset() < stdTimezoneOffset;
    } else {
      var offset1 = getCachedOffsetJanuary(timeZone),
          offset2 = getCachedOffsetJune(timeZone),
          currentOffset = exports.getUtcOffsetInTimeZone(d, timeZone);
      if (offset1 === offset2) {
        return false;
      }
      var daylightSavingsOffset = offset1 > offset2 ? offset1 : offset2;
      if (currentOffset === daylightSavingsOffset) {
        return true;
      } else {
        return false;
      }
    }
  };


  function getDateParts(str) {
    var dateFormatReg = /(\d+).(\d+).(\d+),?\s+(\d+).(\d+)(.(\d+))?/;
    str = str.replace(/[\u200E\u200F]/g, '');
    return [].slice.call(dateFormatReg.exec(str), 1).map(Math.floor);
  }

  function getOffsetMinutesFromDateParts(utcDateParts, timeZoneDateParts) {
    if (utcDateParts[3] === 24) {
      utcDateParts[3] = 0;
    }

    if (timeZoneDateParts[3] === 24) {
      timeZoneDateParts[3] = 0;
    }

    var day =  utcDateParts[1] - timeZoneDateParts[1],
        hour = utcDateParts[3] - timeZoneDateParts[3],
        min = utcDateParts[4] - timeZoneDateParts[4],
        MINUTES_IN_HOUR = 60,
        HOURS_IN_DAY = 24;
    if (day > 15) {
      day = -1;
    }
    if (day < -15) {
      day = 1;
    }
    return MINUTES_IN_HOUR * (HOURS_IN_DAY * day + hour) + min;

  }

  var DATE_TIME_FORMAT_CACHE = {};

  function getDateTimeFormat(id) {
    var options = {
        timeZone: id,
        hour12: false,
        year: 'numeric',
        month: 'numeric',
        day: 'numeric',
        hour: 'numeric',
        minute: 'numeric'
      };

    if (!DATE_TIME_FORMAT_CACHE[id]) {
      DATE_TIME_FORMAT_CACHE[id] = new Intl.DateTimeFormat('en-US', options);
    }
    return DATE_TIME_FORMAT_CACHE[id];
  }

  /**
   * Returns the utc offset in the provided timezone on the given date.
   * This offset will be the number of minutes from UTC with timezones west of
   * UTC being negative and timezones east being positive. For example: EST and
   * EDT would be -300 and -240 respectively.
   * @param {Date} date
   * @param {baja.TimeZone} timeZone
   * @returns {number} the timezone offset in minutes
   */
  exports.getUtcOffsetInTimeZone = function (date, timeZone) {
    var utcDateStr,
      timeZoneDateStr,
      utcDateParts,
      timeZoneDateParts;
    
    try {
      var format = getDateTimeFormat(timeZone.getId());
      timeZoneDateStr = format.format(date);
    } catch (e) {

      if (!hasBeenWarnedTimeZoneNotSupported) {
        hasBeenWarnedTimeZoneNotSupported = true;
        baja.error('Timezone not supported by Intl ' + timeZone.getId());
      }

      return timeZone.getUtcOffset() / 60000;
    }

    utcDateStr = getDateTimeFormat('UTC').format(date);
    timeZoneDateParts = getDateParts(timeZoneDateStr);
    utcDateParts = getDateParts(utcDateStr);
    return getOffsetMinutesFromDateParts(timeZoneDateParts, utcDateParts);
  };

  /**
   * Gets the timezone id for the Niagara station.
   *
   * @returns {string|null}
   */
  exports.getEnvTimeZoneId = function () {
    var envTimeZoneId = window.niagara && window.niagara.env &&
      window.niagara.env.timeZoneId;
    if (envTimeZoneId) {
      return envTimeZoneId;
    } else {
      return exports.getCurrentTimeZoneId();
    }
  };

  /**
   * Format offset in +/-HH:mm
   *
   * @param offset in millis
   * @return {String} +/-HH:mm format of offset
   */
  exports.formatOffset = function (offset) {
    var s = "",
        hrOff = Math.floor(Math.abs(offset / (1000 * 60 * 60))), //use floor since minutes handles the remainder
        minOff = Math.round(Math.abs((offset % (1000 * 60 * 60)) / (1000 * 60)));

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

    if (hrOff < 10) {
      s += "0";
    }
    s += hrOff;

    s += ":";
    if (minOff < 10) {
      s += "0";
    }
    s += minOff;

    return s;
  };

  /**
   *
   * @param {Object} [obj] optional context information
   * @returns {Promise}
   */
  exports.toNullDateTimeString = function (obj = {}) {
    const cb = new baja.comm.Callback(obj.ok, obj.fail, obj.batch);
    // Asynchronously access the baja lexicon...
    lexjs.module("baja")
        .then(function (lex) {
          let str;
          try {
            str = exports.toNullDateTimeStringSync(lex);
          } catch (e) {
            return cb.fail(e);
          }

          cb.ok(str);
        }, cb.fail);

    return cb.promise();
  };

  /**
   *
   * @param {Object} [lex]
   * @returns {string}
   */
  exports.toNullDateTimeStringSync = function (lex) {

    if (!lex || lex.getModuleName() !== 'baja') {
      lex = lexjs.getLexiconFromCache('baja');
    }

    if (lex) {
      return lex.get('AbsTime.null');
    }
    return 'null';
  };
    

  exports.toDateTimeStringSync = function (obj, lex) {

    if (!lex || lex.getModuleName() !== 'baja') {
      lex = lexjs.getLexiconFromCache('baja');
    }
    
    // Get the pattern code
    var pattern,
        s = "",
        sep1 = -1,
        sep2 = -1,
        shownCount = 0,
        c,
        i,
        offset,
        timezone;
    
    if (patternCache.hasOwnProperty(obj.textPattern)) {
      pattern = patternCache[obj.textPattern];
    } else {
      pattern = patternCache[obj.textPattern] = buildPattern(obj.textPattern);      
    }

    timezone = obj.timezone || baja.TimeZone.UTC;

    // walk thru the pattern
    for (i = 0; i < pattern.length; ++i) {
      // get the code
      c = pattern[i];

      // if the code is a separator, save it away and move on
      if (c >= SHOW.length) {
        if (sep1 === -1) {
          sep1 = c;
        } else if (sep2 === -1) {
          sep2 = c;
        }
        continue;
      }

      // if we shouldn't show this field, then clear
      // the pending separator and move on
      if ((SHOW[c] & obj.show) === 0) { 
        sep1 = sep2 = -1;
        continue;
      }

      // we are now going to show this field, so update our show count
      shownCount++;

      // if we have a pending separator then write the separator;
      // note we don't show the separator if this is the first field
      if (shownCount > 1 && sep1 !== -1) {
        s += String.fromCharCode(sep1);
        if (sep2 !== -1) {
          s += String.fromCharCode(sep2);
        }
        sep1 = sep2 = -1;
      }

      // output the field according to the pattern code
      shownCount++;
      switch (c) {
        case YEAR_2:
          // issue 12377
          // old code -> pad(s, year >= 2000 ? year-2000 : year-1900);
          // fix below
          s = pad(s, obj.year % 100);
          break;
        case YEAR_4:
          s += obj.year;
          break;
        case MON_1:
          s += obj.month.getOrdinal() + 1;
          break;
        case MON_2:
          s = pad(s, obj.month.getOrdinal() + 1);
          break;
        case MON_TAG:
          var monthTag = obj.month.getTag();
          if (lex) {
            s += lex.get(monthTag + ".short");
          } else {
            s += monthTag.charAt(0).toUpperCase() +
              monthTag.slice(1, 3).toLowerCase();
          }
          break;
        case MON:
          s.append(obj.month.getDisplayTag());
          break;
        case DAY_1:
          s += obj.day;
          break;
        case DAY_2:
          s = pad(s, obj.day);
          break;
        case HOUR_12_1:
          if (obj.hour === 0) {
            s += "12";
          } else {
            s += obj.hour > 12 ? obj.hour - 12 : obj.hour;
          }
          break;
        case HOUR_12_2:
          if (obj.hour === 0) {
            s += "12";
          } else {
            s = pad(s, obj.hour > 12 ? obj.hour - 12 : obj.hour);
          }
          break;
        case HOUR_24_1:
          s += obj.hour;
          break;
        case HOUR_24_2:
          s = pad(s, obj.hour);
          break;
        case MIN:
          s = pad(s, obj.min);
          break;
        case AM_PM:
          s += obj.hour < 12 ? "AM" : "PM";
          break;
        case SEC:
          s = pad(s, obj.sec);
          if ((obj.show & SHOW_MILLIS) === 0) {
            break;
          }
          s += ".";
          if (obj.ms < 10) {
            s += "0";
          }
          if (obj.ms < 100) {
            s += "0";
          }
          s += obj.ms;
          break;
        case ZONE_TAG:
          var id = timezone.getId(),
              isDst = !!obj.isDst,
              displayName = timezone.getShortDisplayName(isDst);

          //getShortDisplayName matches the behavior of BAbsTime
          if (displayName !== undefined) {
            s += displayName;
          } else {
            s += id;
          }
          break;
        case ZONE_OFFSET:
          offset = timezone.getUtcOffset();

          if (offset === 0) {
            s += "Z";
          } else {
            s += exports.formatOffset(offset);
          }                    
          break;  
        case WEEK_1:
          var weekdayTag = baja.Date.make(obj).getWeekday().getTag();
          if (lex) {
            s += lex.get(weekdayTag + ".short");
          } else {
            s += weekdayTag.charAt(0).toUpperCase() +
              weekdayTag.slice(1, 3).toLowerCase();
          }
          break;
        case WEEK_2:
          s += baja.Date.make(obj).getWeekday().getDisplayTag();
          break;
        case WEEK_YEAR:
          // TODO: Week year
          s += "*** Week year not supported ***";
          break;
      }

      // clear separators
      sep1 = sep2 = -1;
    }
    
    return s;
  };

  var currentTimeZoneId = (function () {
    if (window.Intl) {
      //this is a surprisingly heavy operation, so cache the result.
      //the native browser time zone won't change without a page reload.
      return window.Intl.DateTimeFormat().resolvedOptions().timeZone || null;
    } else {
      return null;
    }
  }());

  /**
   * Gets the current time zone id using the browser's built-in
   * I18N API, or null if it cannot be determined.
   *
   * @private
   * @ignore
   *
   * @returns {string|null}
   */
  exports.getCurrentTimeZoneId = function () {
    return currentTimeZoneId;
  };

  /**
   * Resolve the current timezone.  If a TimeZone is provide, use it, otherwise return the
   * current timezone or null if unknown.
   *
   * @private
   * @ignore
   *
   * @param {baja.TimeZone} [obj.TimeZone]
   * @returns {Promise.<baja.TimeZone|null>}
   */
  exports.resolveTimezone = function (obj) {
    obj = objectify(obj);
    var timezone = obj.TimeZone || obj.timezone;
    if (timezone) {
      return Promise.resolve(timezone);
    } else {
      return baja.TimeZoneDatabase.get().then(function (db) {
        return db.getTimeZone(exports.getCurrentTimeZoneId());
      });
    }
  };
  
  /**
   * Create a formatting date/time String.
   *
   * @private
   * @ignore
   *
   * @param {Object} obj the Object Literal for the method's arguments.
   * @param {Function} [obj.ok] (Deprecated: use Promise) the function callback
   * that will have the formatted String passed to.
   * @param {Function} [obj.fail] (Deprecated: use Promise) the fail function
   * callback that will be called if a fatal error occurs.
   * @param {String} [obj.textPattern]
   * @param {Number} [obj.year]
   * @param {baja.FrozenEnum} [obj.month]
   * @param {Number} [obj.day] days 1 to 31
   * @param {Number} [obj.hour]
   * @param {Number} [obj.min]
   * @param {Number} [obj.sec]
   * @param {Number} [obj.ms]
   * @param {baja.TimeZone} [obj.timezone] If omitted, `UTC` will be used.
   * @param {Boolean} [obj.isDst]  If omitted, this method will consider DST to not be active since
   * the default TimeZone is `UTC`.
   * @param {Number} [obj.show]
   *
   * @returns {Promise.<String>}
   */
  exports.toDateTimeString = function (obj) {
    const cb = new baja.comm.Callback(obj.ok, obj.fail, obj.batch);
    // Asynchronously access the baja lexicon...
    lexjs.module("baja")
      .then(function (lex) {
        let str;
        try {
          str = exports.toDateTimeStringSync(obj, lex);
        } catch (e) {
          return cb.fail(e);
        }

        cb.ok(str);
      }, cb.fail);

    return cb.promise();
  };

  /**
   * Always format the date string using the long year (YYYY) pattern 
   * if the year pattern is part of the display format.
   * 
   * @param {Object} obj the Object Literal for the method's arguments. 
   * @returns {Promise.<String>}
   * @since Niagara 4.13
   */
  exports.toDateTimeStringWithLongYear = function (obj) {
    const timePattern = exports.getTimeFormatPatternWithLongYear(obj.textPattern),
        timePatternWithLongYear = exports.getTimeFormatPatternWithLongYear(timePattern);
    return exports.toDateTimeString(Object.assign({}, obj, { textPattern: timePatternWithLongYear }));
  };

  /**
   * Returns an updated time format to always have the long year format 
   * when the year part is present.
   * 
   * @param {string} timePattern 
   * @returns {string}
   * @since Niagara 4.13 
   */
  exports.getTimeFormatPatternWithLongYear = function (timePattern) {
    timePattern = timePattern || baja.getTimeFormatPattern() || exports.DEFAULT_TIME_FORMAT;
    return timePattern.replace(/\bYY\b/, 'YYYY');
  };

  /**
   * Extract the time-only formatting fields from a timeFormat string.
   *
   * Any preceding non-alphabetic characters before a time field are also
   * extracted under the assumption that they are separators belonging to that
   * field.
   *
   * @param {String} timeFormat - a timeFormat String
   *
   * @returns {String}
   */
  exports.getDateOnlyFormat = function (timeFormat) {
    return siftTimeFormat(timeFormat).dateOnly;
  };
  
  /**
   * Extract the date-only formatting fields from a timeFormat string.
   *
   * Any preceding non-alphabetic characters before a date field are also
   * extracted under the assumption that they are separators belonging to that
   * field.
   *
   * @param {String} timeFormat - a timeFormat String
   *
   * @returns {String}
   */
  exports.getTimeOnlyFormat = function (timeFormat) {
    return siftTimeFormat(timeFormat).timeOnly;
  };

  return exports;
});