/**
* @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;
});