src/timeslot.js
const HashLru = require('hashlru');
const instances = HashLru(1e3);
/**
* A class representing a time slot used in monitoring.
* This can be a given day, epidemiological week, month, quarter, ...
*/
class TimeSlot {
static fromValue(value, check = false) {
let ts = instances.get(value);
if (!ts) {
ts = new TimeSlot(value, check);
instances.set(value, ts);
}
return ts;
}
/**
* @param {Date} utcDate Date which we want to build the TimeSlot around
* @param {string} periodicity One of day, week_sat, week_sun, week_mon, month, quarter, semester, year
* @return {TimeSlot} The TimeSlot instance of the given periodicity containing utcDate
*
* @example
* let ts = TimeSlot.fromDate(new Date(2010, 01, 07, 18, 34), "month");
* ts.value // '2010-01'
*
* let ts2 = TimeSlot.fromDate(new Date(2010, 12, 12, 6, 21), "quarter");
* ts2.value // '2010-Q4'
*/
static fromDate(utcDate, periodicity) {
if (typeof utcDate === 'string') utcDate = new Date(utcDate);
if (periodicity === 'day') return TimeSlot.fromValue(utcDate.toISOString().substring(0, 10));
else if (
periodicity === 'month_week_sat' ||
periodicity === 'month_week_sun' ||
periodicity === 'month_week_mon'
) {
var prefix = utcDate.toISOString().substring(0, 8);
// if no sunday happened in the month OR month start with sunday, week number is one.
var firstDayOfMonth = new Date(
Date.UTC(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), 1)
).getUTCDay();
var firstWeekLength;
if (periodicity === 'month_week_sat') firstWeekLength = 7 - ((firstDayOfMonth + 1) % 7);
else if (periodicity === 'month_week_sun') firstWeekLength = 7 - firstDayOfMonth;
// 1 if month start on saturday, 2 if friday, 7 if sunday
else firstWeekLength = 7 - ((firstDayOfMonth - 1 + 7) % 7);
if (utcDate.getUTCDate() <= firstWeekLength) {
return TimeSlot.fromValue(prefix + 'W1-' + periodicity.substr(-3));
} else {
var weekNumber = Math.floor((utcDate.getUTCDate() - 1 - firstWeekLength) / 7) + 2;
return TimeSlot.fromValue(prefix + 'W' + weekNumber + '-' + periodicity.substr(-3));
}
} else if (
periodicity === 'week_sat' ||
periodicity === 'week_sun' ||
periodicity === 'week_mon'
) {
// Good epoch to count week is the first inferior to searched date (among next, current and last year, in that order).
var year = utcDate.getUTCFullYear() + 1,
epoch = TimeSlot._getEpidemiologicWeekEpoch(year, periodicity);
while (utcDate.getTime() < epoch.getTime())
epoch = TimeSlot._getEpidemiologicWeekEpoch(--year, periodicity);
var weekNumber =
Math.floor((utcDate.getTime() - epoch.getTime()) / 1000 / 60 / 60 / 24 / 7) + 1;
if (weekNumber < 10) weekNumber = '0' + weekNumber;
return TimeSlot.fromValue(year + '-W' + weekNumber + '-' + periodicity.substr(-3));
} else if (periodicity === 'month')
return TimeSlot.fromValue(utcDate.toISOString().substring(0, 7));
else if (periodicity === 'quarter')
return TimeSlot.fromValue(
utcDate.getUTCFullYear().toString() +
'-Q' +
(1 + Math.floor(utcDate.getUTCMonth() / 3)).toString()
);
else if (periodicity === 'semester')
return TimeSlot.fromValue(
utcDate.getUTCFullYear().toString() +
'-S' +
(1 + Math.floor(utcDate.getUTCMonth() / 6)).toString()
);
else if (periodicity === 'year') return TimeSlot.fromValue(utcDate.getUTCFullYear().toString());
else if (periodicity === 'all') return TimeSlot.fromValue('all');
else throw new Error('Invalid periodicity');
}
/**
* Get the date from which we should count weeks to compute the epidemiological week number.
*
* @private
* @todo
* This function is incredibly verbose for what it does.
* Probably a single divmod could give the same result but debugging was nightmarish.
*
* @param {number} year
* @param {string} periodicity
* @return {Date}
*/
static _getEpidemiologicWeekEpoch(year, periodicity) {
var SUNDAY = 0,
MONDAY = 1,
TUESDAY = 2,
WEDNESDAY = 3,
THURSDAY = 4,
FRIDAY = 5,
SATURDAY = 6;
var firstDay = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)).getUTCDay();
var epoch = null;
if (periodicity === 'week_sun') {
if (firstDay === SUNDAY)
// Lucky us, first day of year is Sunday
epoch = Date.UTC(year, 0, 1, 0, 0, 0, 0);
else if (firstDay === MONDAY)
// Epidemiologic week started last day of december
epoch = Date.UTC(year - 1, 11, 31, 0, 0, 0, 0);
else if (firstDay === TUESDAY)
// Epidemiologic week started the previous day (still 2 day in december and 5 in january)
epoch = Date.UTC(year - 1, 11, 30, 0, 0, 0, 0);
else if (firstDay === WEDNESDAY)
// 3 days in december, 4 in january
epoch = Date.UTC(year - 1, 11, 29, 0, 0, 0, 0);
else if (firstDay === THURSDAY)
// we can't have 4 days in december, so the epoch is the 4th of january (the first sunday of the year)
epoch = Date.UTC(year, 0, 4, 0, 0, 0, 0);
else if (firstDay === FRIDAY)
// same as before: first sunday of the year
epoch = Date.UTC(year, 0, 3, 0, 0, 0, 0);
else if (firstDay === SATURDAY)
// same as before: first sunday of the year
epoch = Date.UTC(year, 0, 2, 0, 0, 0, 0);
} else if (periodicity === 'week_sat') {
if (firstDay === SATURDAY)
// Lucky us, first day of year is Saturday
epoch = Date.UTC(year, 0, 1, 0, 0, 0, 0);
else if (firstDay === SUNDAY)
// Epidemiologic week started last day of december
epoch = Date.UTC(year - 1, 11, 31, 0, 0, 0, 0);
else if (firstDay === MONDAY)
// Epidemiologic week started the previous day (still 2 day in december and 5 in january)
epoch = Date.UTC(year - 1, 11, 30, 0, 0, 0, 0);
else if (firstDay === TUESDAY)
// 3 days in december, 4 in january
epoch = Date.UTC(year - 1, 11, 29, 0, 0, 0, 0);
else if (firstDay === WEDNESDAY)
// we can't have 4 days in december, so the epoch is the 4th of january (the first saturday of the year)
epoch = Date.UTC(year, 0, 4, 0, 0, 0, 0);
else if (firstDay === THURSDAY)
// same as before: first saturday of the year
epoch = Date.UTC(year, 0, 3, 0, 0, 0, 0);
else if (firstDay === FRIDAY)
// same as before: first saturday of the year
epoch = Date.UTC(year, 0, 2, 0, 0, 0, 0);
} else if (periodicity === 'week_mon') {
if (firstDay === MONDAY)
// Lucky us, first day of year is Sunday
epoch = Date.UTC(year, 0, 1, 0, 0, 0, 0);
else if (firstDay === TUESDAY)
// Epidemiologic week started last day of december
epoch = Date.UTC(year - 1, 11, 31, 0, 0, 0, 0);
else if (firstDay === WEDNESDAY)
// Epidemiologic week started the previous day (still 2 day in december and 5 in january)
epoch = Date.UTC(year - 1, 11, 30, 0, 0, 0, 0);
else if (firstDay === THURSDAY)
// 3 days in december, 4 in january
epoch = Date.UTC(year - 1, 11, 29, 0, 0, 0, 0);
else if (firstDay === FRIDAY)
// we can't have 4 days in december, so the epoch is the 4th of january (the first monday of the year)
epoch = Date.UTC(year, 0, 4, 0, 0, 0, 0);
else if (firstDay === SATURDAY)
// same as before: first monday of the year
epoch = Date.UTC(year, 0, 3, 0, 0, 0, 0);
else if (firstDay === SUNDAY)
// same as before: first monday of the year
epoch = Date.UTC(year, 0, 2, 0, 0, 0, 0);
} else throw new Error('Invalid day');
return new Date(epoch);
}
/**
* Constructs a TimeSlot instance from a time slot value.
* The periodicity will be automatically computed.
*
* @param {string} value A valid TimeSlot value (those can be found calling the `value` getter).
*/
constructor(value, check = false) {
this._value = value;
this._firstDate = null;
this._lastDate = null;
this._previous = null;
this._next = null;
this._parents = {};
this._children = {};
// Poor man's parser.
// The previous versions based on regexps was on the top of the profiler on monitool.
const len = value.length;
if (len === 3) {
this._periodicity = 'all';
} else if (len === 4) {
this._periodicity = 'year';
} else if (len === 7) {
const charAt6 = value[5];
if (charAt6 === 'Q') {
this._periodicity = 'quarter';
} else if (charAt6 === 'S') {
this._periodicity = 'semester';
} else {
this._periodicity = 'month';
}
} else if (len == 10) {
this._periodicity = 'day';
} else if (len == 12) {
this._periodicity = 'week_' + value.substr(9);
} else if (len == 14) {
this._periodicity = 'month_week_' + value.substr(11);
}
if (check) {
try {
const newValue = TimeSlot.fromDate(this.firstDate, this.periodicity);
if (newValue !== value) {
throw new Error();
}
} catch (e) {
throw new Error('Invalid value');
}
}
}
/**
* The value of the TimeSlot.
* This is a string that uniquely identifies this timeslot.
*
* For instance: `2010`, `2010-Q1`, `2010-W07-sat`.
* @type {string}
*/
get value() {
return this._value;
}
/**
* The periodicity used for this timeslot.
* By periodicity, we mean, the method that was used to cut time into slots.
*
* For instance: `year`, `quarter`, `week-sat`, ...
* @type {string}
*/
get periodicity() {
return this._periodicity;
}
/**
* The date where this instance of TimeSlot begins.
*
* @type {Date}
* @example
* var t = TimeSlot.fromValue('2012-01');
* t.firstDate.toUTCString(); // 2012-01-01T00:00:00Z
*/
get firstDate() {
if (!this._firstDate) {
if (this._periodicity === 'day') this._firstDate = new Date(this._value + 'T00:00:00Z');
else if (
this._periodicity === 'month_week_sat' ||
this._periodicity === 'month_week_sun' ||
this._periodicity === 'month_week_mon'
) {
var weekNumber = 1 * this._value.substr(9, 1);
var firstDayOfMonth = new Date(this._value.substring(0, 7) + '-01T00:00:00Z').getUTCDay();
if (weekNumber === 1)
this._firstDate = new Date(
Date.UTC(this._value.substring(0, 4), this._value.substring(5, 7) - 1, 1)
);
else {
var firstWeekLength;
if (this._periodicity === 'month_week_sat')
firstWeekLength = 7 - ((firstDayOfMonth + 1) % 7);
else if (this._periodicity === 'month_week_sun') firstWeekLength = 7 - firstDayOfMonth;
// 1 if month start on saturday, 2 if friday, 7 if sunday
else firstWeekLength = 7 - ((firstDayOfMonth - 1 + 7) % 7);
this._firstDate = new Date(
Date.UTC(
this._value.substring(0, 4),
this._value.substring(5, 7) - 1,
1 + firstWeekLength + (weekNumber - 2) * 7
)
);
}
} else if (
this._periodicity === 'week_sat' ||
this._periodicity === 'week_sun' ||
this._periodicity === 'week_mon'
)
this._firstDate = new Date(
TimeSlot._getEpidemiologicWeekEpoch(
this._value.substring(0, 4),
this._periodicity
).getTime() +
(this._value.substring(6, 8) - 1) * 7 * 24 * 60 * 60 * 1000 // week numbering starts with 1
);
else if (this._periodicity === 'month')
this._firstDate = new Date(this._value + '-01T00:00:00Z');
else if (this._periodicity === 'quarter') {
var month = (this._value.substring(6, 7) - 1) * 3 + 1;
if (month < 10) month = '0' + month;
this._firstDate = new Date(this._value.substring(0, 5) + month + '-01T00:00:00Z');
} else if (this._periodicity === 'semester') {
var month2 = (this._value.substring(6, 7) - 1) * 6 + 1;
if (month2 < 10) month2 = '0' + month2;
this._firstDate = new Date(this._value.substring(0, 5) + month2 + '-01T00:00:00Z');
} else if (this._periodicity === 'year')
this._firstDate = new Date(this._value + '-01-01T00:00:00Z');
else if (this._periodicity === 'all') this._firstDate = new Date(-8640000000000000);
}
return this._firstDate;
}
/**
* The date where this instance of TimeSlot ends.
*
* @type {Date}
* @example
* var t = TimeSlot.fromValue('2012-01');
* t.firstDate.toUTCString(); // 2012-01-31T00:00:00Z
*/
get lastDate() {
if (!this._lastDate) {
if (this._periodicity === 'day')
// last day is current day
this._lastDate = this.firstDate;
else if (
this._periodicity === 'month_week_sat' ||
this._periodicity === 'month_week_sun' ||
this._periodicity === 'month_week_mon'
) {
var weekNumber = this._value.substr(9, 1);
var firstDayOfMonth = new Date(this._value.substring(0, 7) + '-01T00:00:00Z').getUTCDay();
var firstWeekLength;
if (this._periodicity === 'month_week_sat')
firstWeekLength = 7 - ((firstDayOfMonth + 1) % 7);
else if (this._periodicity === 'month_week_sun') firstWeekLength = 7 - firstDayOfMonth;
// 1 if month start on saturday, 2 if friday, 7 if sunday
else firstWeekLength = 7 - ((firstDayOfMonth - 1 + 7) % 7);
if (weekNumber === 1)
this._lastDate = new Date(
Date.UTC(this._value.substring(0, 4), this._value.substring(5, 7) - 1, firstWeekLength)
);
else {
var res = new Date(
Date.UTC(
this._value.substring(0, 4),
this._value.substring(5, 7) - 1,
1 + 6 + firstWeekLength + (weekNumber - 2) * 7
)
);
if (res.getUTCMonth() !== this._value.substring(5, 7) - 1) res.setUTCDate(0); // go to last day of previous month.
this._lastDate = res;
}
} else if (
this._periodicity === 'week_sat' ||
this._periodicity === 'week_sun' ||
this._periodicity === 'week_mon'
) {
// last day is last day of the week according to epoch
this._lastDate = new Date(this.firstDate.getTime() + 6 * 24 * 60 * 60 * 1000);
} else if (this._periodicity === 'month') {
var monthDate = new Date(this.firstDate.valueOf());
monthDate.setUTCMonth(monthDate.getUTCMonth() + 1); // add one month.
monthDate.setUTCDate(0); // go to last day of previous month.
this._lastDate = monthDate;
} else if (this._periodicity === 'quarter') {
var quarterDate = new Date(this.firstDate.valueOf());
quarterDate.setUTCMonth(quarterDate.getUTCMonth() + 3); // add three month.
quarterDate.setUTCDate(0); // go to last day of previous month.
this._lastDate = quarterDate;
} else if (this._periodicity === 'semester') {
var semesterDate = new Date(this.firstDate.valueOf());
semesterDate.setUTCMonth(semesterDate.getUTCMonth() + 6); // add six month.
semesterDate.setUTCDate(0); // go to last day of previous month.
this._lastDate = semesterDate;
} else if (this._periodicity === 'year')
this._lastDate = new Date(this._value + '-12-31T00:00:00Z');
else if (this._periodicity === 'all') this._lastDate = new Date(8640000000000000);
}
return this._lastDate;
}
get parentPeriodicities() {
return TimeSlot.upperSlots[this._periodicity];
}
get childPeriodicities() {
return Object.keys(TimeSlot.upperSlots).filter(
p => TimeSlot.upperSlots[p].indexOf(this._periodicity) !== -1
);
}
/**
* Creates a TimeSlot instance with a longer periodicity that contains this one.
*
* @param {string} newPeriodicity The desired periodicity
* @return {TimeSlot} A TimeSlot.fromValue instance.
*
* @example
* let t = TimeSlot.fromValue('2010-07'),
* t2 = t.toParentPeriodicity('quarter');
*
* t2.value; // 2010-Q3
*/
toParentPeriodicity(newPeriodicity) {
if (!this._parents[newPeriodicity]) {
if (newPeriodicity == this._periodicity) {
this._parents[newPeriodicity] = this;
} else {
// Raise when we make invalid conversions
if (this.parentPeriodicities.indexOf(newPeriodicity) === -1)
throw new Error('Cannot convert ' + this._periodicity + ' to ' + newPeriodicity);
// For days, months, quarters, semesters, we can assume that getting the slot from any date works
var upperSlotDate = this.firstDate;
// if it's a week, we need to be a bit more cautious.
// the month/quarter/year is not that of the first or last day, but that of the middle day of the week
// (which depend on the kind of week, but adding 3 days to the beginning gives the good date).
if (
this._periodicity === 'week_sat' ||
this._periodicity === 'week_sun' ||
this._periodicity === 'week_mon'
)
upperSlotDate = new Date(upperSlotDate.getTime() + 3 * 24 * 60 * 60 * 1000);
this._parents[newPeriodicity] = TimeSlot.fromDate(upperSlotDate, newPeriodicity);
}
}
return this._parents[newPeriodicity];
}
toChildPeriodicity(newPeriodicity) {
if (!this._children[newPeriodicity]) {
if (this._periodicity === 'all')
throw new Error('Would yield an infinite amount of children');
if (this._periodicity == newPeriodicity) this._children[newPeriodicity] = [this];
else {
// Invalid conversions
if (this.childPeriodicities.indexOf(newPeriodicity) === -1)
throw new Error('Cannot convert ' + this._periodicity + ' to ' + newPeriodicity);
const end = TimeSlot.fromDate(this.lastDate, newPeriodicity);
let current = TimeSlot.fromDate(this.firstDate, newPeriodicity);
const result = [current];
while (current.value !== end.value) {
current = current.next();
result.push(current);
}
this._children[newPeriodicity] = result;
}
}
return this._children[newPeriodicity];
}
previous() {
if (!this._previous) {
if (this._periodicity === 'all') throw new Error('There is no previous slot');
var date = new Date(this.firstDate.valueOf());
date.setUTCDate(date.getUTCDate() - 1);
this._previous = TimeSlot.fromDate(date, this._periodicity);
}
return this._previous;
}
/**
* Creates a TimeSlot instance of the same periodicity than the current once, but which follows it
*
* @return {TimeSlot}
* @example
* var ts = TimeSlot.fromValue('2010');
* ts.next().value // 2011
*
* var ts2 = TimeSlot.fromValue('2010-W52-sat');
* ts.next().value // 2011-W01-sat
*/
next() {
if (!this._next) {
if (this._periodicity === 'all') throw new Error('There is no next slot');
var date = new Date(this.lastDate.valueOf());
date.setUTCDate(date.getUTCDate() + 1);
this._next = TimeSlot.fromDate(date, this._periodicity);
}
return this._next;
}
/**
* Humanize the TimeSlot periodicity
*
* @param {'en'|'fr'|'es'} language
* @return string Humanized label
*/
humanizePeriodicity(language = 'en') {
// Protect against remote code execution if run server-side.
if (!/^[a-z]{2}$/.test(language)) throw new Error('Language does not match expected format');
try {
const locale = require('./locale/' + language);
return locale.humanizePeriodicity(this._periodicity);
} catch (e) {
throw new Error('Requested locale is not defined.');
}
}
/**
* Humanize the TimeSlot value
*
* @param {'en'|'fr'|'es'} language
* @return string Humanized label
*/
humanizeValue(language = 'en') {
// Protect against remote code execution if run server-side.
if (!/^[a-z]{2}$/.test(language)) throw new Error('Language does not match expected format');
try {
const locale = require('./locale/' + language);
return locale.humanizeValue(this._periodicity, this._value);
} catch (e) {
throw new Error('Requested locale is not defined.');
}
}
}
/**
* Static member documenting which periodicity contains the others.
*
* @private
* @type {Object}
*/
TimeSlot.upperSlots = {
day: [
'month_week_sat',
'month_week_sun',
'month_week_mon',
'week_sat',
'week_sun',
'week_mon',
'month',
'quarter',
'semester',
'year',
'all',
],
month_week_sat: ['week_sat', 'month', 'quarter', 'semester', 'year', 'all'],
month_week_sun: ['week_sun', 'month', 'quarter', 'semester', 'year', 'all'],
month_week_mon: ['week_mon', 'month', 'quarter', 'semester', 'year', 'all'],
week_sat: ['month', 'quarter', 'semester', 'year', 'all'],
week_sun: ['month', 'quarter', 'semester', 'year', 'all'],
week_mon: ['month', 'quarter', 'semester', 'year', 'all'],
month: ['quarter', 'semester', 'year', 'all'],
quarter: ['semester', 'year', 'all'],
semester: ['year', 'all'],
year: ['all'],
all: [],
};
module.exports = TimeSlot;