const FormulaError = require('../error');
const {FormulaHelpers, Types} = require('../helpers');
const H = FormulaHelpers;
const MS_PER_DAY = 1000 * 60 * 60 * 24;
const d1900 = new Date(Date.UTC(1900, 0, 1));
const WEEK_STARTS = [
undefined, 0, 1, undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, 1, 2, 3, 4, 5, 6, 0];
const WEEK_TYPES = [
undefined,
[1, 2, 3, 4, 5, 6, 7],
[7, 1, 2, 3, 4, 5, 6],
[6, 0, 1, 2, 3, 4, 5],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
[7, 1, 2, 3, 4, 5, 6],
[6, 7, 1, 2, 3, 4, 5],
[5, 6, 7, 1, 2, 3, 4],
[4, 5, 6, 7, 1, 2, 3],
[3, 4, 5, 6, 7, 1, 2],
[2, 3, 4, 5, 6, 7, 1],
[1, 2, 3, 4, 5, 6, 7]
];
const WEEKEND_TYPES = [
undefined,
[6, 0],
[0, 1],
[1, 2],
[2, 3],
[3, 4],
[4, 5],
[5, 6],
undefined,
undefined,
undefined,
[0],
[1],
[2],
[3],
[4],
[5],
[6]
];
// Formats: h:mm:ss A, h:mm A, H:mm, H:mm:ss, H A
const timeRegex = /^\s*(\d\d?)\s*(:\s*\d\d?)?\s*(:\s*\d\d?)?\s*(pm|am)?\s*$/i;
// 12-3, 12/3
const dateRegex1 = /^\s*((\d\d?)\s*([-\/])\s*(\d\d?))([\d:.apm\s]*)$/i;
// 3-Dec, 3/Dec
const dateRegex2 = /^\s*((\d\d?)\s*([-/])\s*(jan\w*|feb\w*|mar\w*|apr\w*|may\w*|jun\w*|jul\w*|aug\w*|sep\w*|oct\w*|nov\w*|dec\w*))([\d:.apm\s]*)$/i;
// Dec-3, Dec/3
const dateRegex3 = /^\s*((jan\w*|feb\w*|mar\w*|apr\w*|may\w*|jun\w*|jul\w*|aug\w*|sep\w*|oct\w*|nov\w*|dec\w*)\s*([-/])\s*(\d\d?))([\d:.apm\s]*)$/i;
function parseSimplifiedDate(text) {
const fmt1 = text.match(dateRegex1);
const fmt2 = text.match(dateRegex2);
const fmt3 = text.match(dateRegex3);
if (fmt1) {
text = fmt1[1] + fmt1[3] + new Date().getFullYear() + fmt1[5];
} else if (fmt2) {
text = fmt2[1] + fmt2[3] + new Date().getFullYear() + fmt2[5];
} else if (fmt3) {
text = fmt3[1] + fmt3[3] + new Date().getFullYear() + fmt3[5];
}
return new Date(Date.parse(`${text} UTC`));
}
/**
* Parse time string to date in UTC.
* @param {string} text
*/
function parseTime(text) {
const res = text.match(timeRegex);
if (!res) return;
// ["4:50:55 pm", "4", ":50", ":55", "pm", ...]
const minutes = res[2] ? res[2] : ':00';
const seconds = res[3] ? res[3] : ':00';
const ampm = res[4] ? ' ' + res[4] : '';
const date = new Date(Date.parse(`1/1/1900 ${res[1] + minutes + seconds + ampm} UTC`));
let now = new Date();
now = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(),
now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()));
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(),
date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()));
}
/**
* Parse a UTC date to excel serial number.
* @param {Date|number} date - A UTC date.
* @returns {number}
*/
function toSerial(date) {
const addOn = (date > -2203891200000) ? 2 : 1;
return Math.floor((date - d1900) / 86400000) + addOn;
}
/**
* Parse an excel serial number to UTC date.
* @param serial
* @returns {Date}
*/
function toDate(serial) {
if (serial < 0) {
throw FormulaError.VALUE;
}
if (serial <= 60) {
return new Date(d1900.getTime() + (serial - 1) * 86400000);
}
return new Date(d1900.getTime() + (serial - 2) * 86400000);
}
function parseDateWithExtra(serialOrString) {
if (serialOrString instanceof Date) return {date: serialOrString};
serialOrString = H.accept(serialOrString);
let isDateGiven = true, date;
if (!isNaN(serialOrString)) {
serialOrString = Number(serialOrString);
date = toDate(serialOrString);
} else {
// support time without date
date = parseTime(serialOrString);
if (!date) {
date = parseSimplifiedDate(serialOrString);
} else {
isDateGiven = false;
}
}
return {date, isDateGiven};
}
function parseDate(serialOrString) {
return parseDateWithExtra(serialOrString).date;
}
function compareDateIgnoreTime(date1, date2) {
return date1.getUTCFullYear() === date2.getUTCFullYear() &&
date1.getUTCMonth() === date2.getUTCMonth() &&
date1.getUTCDate() === date2.getUTCDate();
}
function isLeapYear(year) {
if (year === 1900) {
return true;
}
return new Date(year, 1, 29).getMonth() === 1;
}
const DateFunctions = {
DATE: (year, month, day) => {
year = H.accept(year, Types.NUMBER);
month = H.accept(month, Types.NUMBER);
day = H.accept(day, Types.NUMBER);
if (year < 0 || year >= 10000)
throw FormulaError.NUM;
// If year is between 0 (zero) and 1899 (inclusive), Excel adds that value to 1900 to calculate the year.
if (year < 1900) {
year += 1900;
}
return toSerial(Date.UTC(year, month - 1, day));
},
DATEDIF: (startDate, endDate, unit) => {
startDate = parseDate(startDate);
endDate = parseDate(endDate);
unit = H.accept(unit, Types.STRING).toLowerCase();
if (startDate > endDate)
throw FormulaError.NUM;
const yearDiff = endDate.getUTCFullYear() - startDate.getUTCFullYear();
const monthDiff = endDate.getUTCMonth() - startDate.getUTCMonth();
const dayDiff = endDate.getUTCDate() - startDate.getUTCDate();
let offset;
switch (unit) {
case 'y':
offset = monthDiff < 0 || monthDiff === 0 && dayDiff < 0 ? -1 : 0;
return offset + yearDiff;
case 'm':
offset = dayDiff < 0 ? -1 : 0;
return yearDiff * 12 + monthDiff + offset;
case 'd':
return Math.floor(endDate - startDate) / MS_PER_DAY;
case 'md':
// The months and years of the dates are ignored.
startDate.setUTCFullYear(endDate.getUTCFullYear());
if (dayDiff < 0) {
startDate.setUTCMonth(endDate.getUTCMonth() - 1)
} else {
startDate.setUTCMonth(endDate.getUTCMonth())
}
return Math.floor(endDate - startDate) / MS_PER_DAY;
case 'ym':
// The days and years of the dates are ignored
offset = dayDiff < 0 ? -1 : 0;
return (offset + yearDiff * 12 + monthDiff) % 12;
case 'yd':
// The years of the dates are ignored.
if (monthDiff < 0 || monthDiff === 0 && dayDiff < 0) {
startDate.setUTCFullYear(endDate.getUTCFullYear() - 1);
} else {
startDate.setUTCFullYear(endDate.getUTCFullYear());
}
return Math.floor(endDate - startDate) / MS_PER_DAY;
}
},
/**
* Limitation: Year must be four digit, only support ISO 8016 date format.
* Does not support date without year, i.e. "5-JUL".
* @param {string} dateText
*/
DATEVALUE: (dateText) => {
dateText = H.accept(dateText, Types.STRING);
const {date, isDateGiven} = parseDateWithExtra(dateText);
if (!isDateGiven) return 0;
const serial = toSerial(date);
if (serial < 0 || serial > 2958465)
throw FormulaError.VALUE;
return serial;
},
DAY: serialOrString => {
const date = parseDate(serialOrString);
return date.getUTCDate();
},
DAYS: (endDate, startDate) => {
endDate = parseDate(endDate);
startDate = parseDate(startDate);
let offset = 0;
if (startDate < -2203891200000 && -2203891200000 < endDate) {
offset = 1;
}
return Math.floor(endDate - startDate) / MS_PER_DAY + offset;
},
DAYS360: (startDate, endDate, method) => {
startDate = parseDate(startDate);
endDate = parseDate(endDate);
// default is US method
method = H.accept(method, Types.BOOLEAN, false);
if (startDate.getUTCDate() === 31) {
startDate.setUTCDate(30);
}
if (!method && startDate.getUTCDate() < 30 && endDate.getUTCDate() > 30) {
endDate.setUTCMonth(endDate.getUTCMonth() + 1, 1);
} else {
// European method
if (endDate.getUTCDate() === 31) {
endDate.setUTCDate(30);
}
}
const yearDiff = endDate.getUTCFullYear() - startDate.getUTCFullYear();
const monthDiff = endDate.getUTCMonth() - startDate.getUTCMonth();
const dayDiff = endDate.getUTCDate() - startDate.getUTCDate();
return (monthDiff) * 30 + dayDiff + yearDiff * 12 * 30;
},
EDATE: (startDate, months) => {
startDate = parseDate(startDate);
months = H.accept(months, Types.NUMBER);
startDate.setUTCMonth(startDate.getUTCMonth() + months);
return toSerial(startDate);
},
EOMONTH: (startDate, months) => {
startDate = parseDate(startDate);
months = H.accept(months, Types.NUMBER);
startDate.setUTCMonth(startDate.getUTCMonth() + months + 1, 0);
return toSerial(startDate);
},
HOUR: serialOrString => {
const date = parseDate(serialOrString);
return date.getUTCHours();
},
ISOWEEKNUM: (serialOrString) => {
const date = parseDate(serialOrString);
// https://stackoverflow.com/questions/6117814/get-week-of-year-in-javascript-like-in-php
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay();
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
},
MINUTE: serialOrString => {
const date = parseDate(serialOrString);
return date.getUTCMinutes();
},
MONTH: serialOrString => {
const date = parseDate(serialOrString);
return date.getUTCMonth() + 1;
},
NETWORKDAYS: (startDate, endDate, holidays) => {
startDate = parseDate(startDate);
endDate = parseDate(endDate);
let sign = 1;
if (startDate > endDate) {
sign = -1;
const temp = startDate;
startDate = endDate;
endDate = temp;
}
const holidaysArr = [];
if (holidays != null) {
H.flattenParams([holidays], Types.NUMBER, false, item => {
holidaysArr.push(parseDate(item));
});
}
let numWorkDays = 0;
while (startDate <= endDate) {
// Skips Sunday and Saturday
if (startDate.getUTCDay() !== 0 && startDate.getUTCDay() !== 6) {
let found = false;
for (let i = 0; i < holidaysArr.length; i++) {
if (compareDateIgnoreTime(startDate, holidaysArr[i])) {
found = true;
break;
}
}
if (!found) numWorkDays++;
}
startDate.setUTCDate(startDate.getUTCDate() + 1);
}
return sign * numWorkDays;
},
'NETWORKDAYS.INTL': (startDate, endDate, weekend, holidays) => {
startDate = parseDate(startDate);
endDate = parseDate(endDate);
let sign = 1;
if (startDate > endDate) {
sign = -1;
const temp = startDate;
startDate = endDate;
endDate = temp;
}
weekend = H.accept(weekend, null, 1);
// Using 1111111 will always return 0.
if (weekend === '1111111')
return 0;
// using weekend string, i.e, 0000011
if (typeof weekend === "string" && Number(weekend).toString() !== weekend) {
if (weekend.length !== 7) throw FormulaError.VALUE;
weekend = weekend.charAt(6) + weekend.slice(0, 6);
const weekendArr = [];
for (let i = 0; i < weekend.length; i++) {
if (weekend.charAt(i) === '1')
weekendArr.push(i);
}
weekend = weekendArr;
} else {
// using weekend number
if (typeof weekend !== "number")
throw FormulaError.VALUE;
weekend = WEEKEND_TYPES[weekend];
}
const holidaysArr = [];
if (holidays != null) {
H.flattenParams([holidays], Types.NUMBER, false, item => {
holidaysArr.push(parseDate(item));
});
}
let numWorkDays = 0;
while (startDate <= endDate) {
let skip = false;
for (let i = 0; i < weekend.length; i++) {
if (weekend[i] === startDate.getUTCDay()) {
skip = true;
break;
}
}
if (!skip) {
let found = false;
for (let i = 0; i < holidaysArr.length; i++) {
if (compareDateIgnoreTime(startDate, holidaysArr[i])) {
found = true;
break;
}
}
if (!found) numWorkDays++;
}
startDate.setUTCDate(startDate.getUTCDate() + 1);
}
return sign * numWorkDays;
},
NOW: () => {
const now = new Date();
return toSerial(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(),
now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()))
+ (3600 * now.getHours() + 60 * now.getMinutes() + now.getSeconds()) / 86400;
},
SECOND: (serialOrString) => {
const date = parseDate(serialOrString);
return date.getUTCSeconds();
},
TIME: (hour, minute, second) => {
hour = H.accept(hour, Types.NUMBER);
minute = H.accept(minute, Types.NUMBER);
second = H.accept(second, Types.NUMBER);
if (hour < 0 || hour > 32767 || minute < 0 || minute > 32767 || second < 0 || second > 32767)
throw FormulaError.NUM;
return (3600 * hour + 60 * minute + second) / 86400;
},
TIMEVALUE: (timeText) => {
timeText = parseDate(timeText);
return (3600 * timeText.getUTCHours() + 60 * timeText.getUTCMinutes() + timeText.getUTCSeconds()) / 86400;
},
TODAY: () => {
const now = new Date();
return toSerial(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
},
WEEKDAY: (serialOrString, returnType) => {
const date = parseDate(serialOrString);
returnType = H.accept(returnType, Types.NUMBER, 1);
const day = date.getUTCDay();
const weekTypes = WEEK_TYPES[returnType];
if (!weekTypes)
throw FormulaError.NUM;
return weekTypes[day];
},
WEEKNUM: (serialOrString, returnType) => {
const date = parseDate(serialOrString);
returnType = H.accept(returnType, Types.NUMBER, 1);
if (returnType === 21) {
return DateFunctions.ISOWEEKNUM(serialOrString);
}
const weekStart = WEEK_STARTS[returnType];
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const offset = yearStart.getUTCDay() < weekStart ? 1 : 0;
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7) + offset;
},
WORKDAY: (startDate, days, holidays) => {
return DateFunctions["WORKDAY.INTL"](startDate, days, 1, holidays);
},
'WORKDAY.INTL': (startDate, days, weekend, holidays) => {
startDate = parseDate(startDate);
days = H.accept(days, Types.NUMBER);
weekend = H.accept(weekend, null, 1);
// Using 1111111 will always return value error.
if (weekend === '1111111')
throw FormulaError.VALUE;
// using weekend string, i.e, 0000011
if (typeof weekend === "string" && Number(weekend).toString() !== weekend) {
if (weekend.length !== 7)
throw FormulaError.VALUE;
weekend = weekend.charAt(6) + weekend.slice(0, 6);
const weekendArr = [];
for (let i = 0; i < weekend.length; i++) {
if (weekend.charAt(i) === '1')
weekendArr.push(i);
}
weekend = weekendArr;
} else {
// using weekend number
if (typeof weekend !== "number")
throw FormulaError.VALUE;
weekend = WEEKEND_TYPES[weekend];
if (weekend == null)
throw FormulaError.NUM;
}
const holidaysArr = [];
if (holidays != null) {
H.flattenParams([holidays], Types.NUMBER, false, item => {
holidaysArr.push(parseDate(item));
});
}
startDate.setUTCDate(startDate.getUTCDate() + 1);
let cnt = 0;
while (cnt < days) {
let skip = false;
for (let i = 0; i < weekend.length; i++) {
if (weekend[i] === startDate.getUTCDay()) {
skip = true;
break;
}
}
if (!skip) {
let found = false;
for (let i = 0; i < holidaysArr.length; i++) {
if (compareDateIgnoreTime(startDate, holidaysArr[i])) {
found = true;
break;
}
}
if (!found) cnt++;
}
startDate.setUTCDate(startDate.getUTCDate() + 1);
}
return toSerial(startDate) - 1;
},
YEAR: (serialOrString) => {
const date = parseDate(serialOrString);
return date.getUTCFullYear();
},
// Warning: may have bugs
YEARFRAC: (startDate, endDate, basis) => {
startDate = parseDate(startDate);
endDate = parseDate(endDate);
if (startDate > endDate) {
const temp = startDate;
startDate = endDate;
endDate = temp;
}
basis = H.accept(basis, Types.NUMBER, 0);
basis = Math.trunc(basis);
if (basis < 0 || basis > 4)
throw FormulaError.VALUE;
// https://github.com/LesterLyu/formula.js/blob/develop/lib/date-time.js#L508
let sd = startDate.getUTCDate();
const sm = startDate.getUTCMonth() + 1;
const sy = startDate.getUTCFullYear();
let ed = endDate.getUTCDate();
const em = endDate.getUTCMonth() + 1;
const ey = endDate.getUTCFullYear();
switch (basis) {
case 0:
// US (NASD) 30/360
if (sd === 31 && ed === 31) {
sd = 30;
ed = 30;
} else if (sd === 31) {
sd = 30;
} else if (sd === 30 && ed === 31) {
ed = 30;
}
return Math.abs((ed + em * 30 + ey * 360) - (sd + sm * 30 + sy * 360)) / 360;
case 1:
// Actual/actual
if (ey - sy < 2) {
const yLength = isLeapYear(sy) && sy !== 1900 ? 366 : 365;
const days = DateFunctions.DAYS(endDate, startDate);
return days / yLength;
} else {
const years = (ey - sy) + 1;
const days = (new Date(ey + 1, 0, 1) - new Date(sy, 0, 1)) / 1000 / 60 / 60 / 24;
const average = days / years;
return DateFunctions.DAYS(endDate, startDate) / average;
}
case 2:
// Actual/360
return Math.abs(DateFunctions.DAYS(endDate, startDate) / 360);
case 3:
// Actual/365
return Math.abs(DateFunctions.DAYS(endDate, startDate) / 365);
case 4:
// European 30/360
return Math.abs((ed + em * 30 + ey * 360) - (sd + sm * 30 + sy * 360)) / 360;
}
},
};
module.exports = DateFunctions;