calendar-date
Version:
Immutable object to represent a calendar date with zero dependencies
490 lines (431 loc) • 14.6 kB
text/typescript
const DAY_IN_SECONDS = 86400;
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
export enum DayOfTheWeek {
MONDAY = 1,
TUESDAY = 2,
WEDNESDAY = 3,
THURSDAY = 4,
FRIDAY = 5,
SATURDAY = 6,
SUNDAY = 7,
}
function parseIsoString(isoString: string): { year: number; month: number; day: number } {
if (!isoString.match(new RegExp(/^\d{4}-\d{2}-\d{2}$/))) {
throw new Error(
`CalendarDate Validation Error: Input ${isoString.toString()} is not valid, it should follow the pattern YYYY-MM-DD.`,
);
}
const split = isoString.split('-');
return {
year: parseInt(split[0]),
month: parseInt(split[1]),
day: parseInt(split[2]),
};
}
export class CalendarDate {
readonly year: number;
/**
* Month of the year starting from 1
*/
readonly month: number;
/**
* Day of the month starting from 1
*/
readonly day: number;
/**
* Seconds passed since the unix epoch. Always calculated with a time and timezone set to T00:00:00.00Z.
*/
readonly unixTimestampInSeconds: number;
/**
* Day of the week starting from monday according to ISO 8601. Values from 1-7.
*/
readonly weekday: number;
/**
* cache for Intl.DateTimeFormat instances
* @private
*/
private static dateTimeFormatterByTimezone = new Map<string, Intl.DateTimeFormat>();
/**
* Customizes the default string description for instances of `CalendarDate`.
*/
get [Symbol.toStringTag]() {
return 'CalendarDate';
}
/**
* Throws an Error for invalid inputs.
*
* @param isoString Format: yyyy-MM-dd
*/
constructor(isoString: string);
/**
* Throws an Error for invalid inputs.
*
* @param year Integer between 1-9999, other inputs may lead to unstable behaviour
* @param month Integer between 1-12
* @param day Integer between 1-31
*/
constructor(year: number, month: number, day: number);
constructor(input1: string | number, input2?: number, input3?: number) {
if (typeof input1 === 'string') {
const result = parseIsoString(input1);
this.year = result.year;
this.month = result.month;
this.day = result.day;
} else if (
typeof input1 === 'number' &&
typeof input2 === 'number' &&
typeof input3 === 'number'
) {
this.year = input1;
this.month = input2;
this.day = input3;
} else {
const inputs = [input1, input2, input3].filter((input) => input !== undefined);
throw new Error(
`CalendarDate Validation Error: Input [ ${inputs.join(
' , ',
)} ] of type [ ${inputs.map((input) => typeof input).join(' , ')} ] is not valid.`,
);
}
if (this.year < 0 || this.year > 9999) {
throw new Error(
`CalendarDate Validation Error: Input year ${this.year} is not valid. Year must be a number between 0 and 9999.`,
);
}
if (this.month < 1 || this.month > 12) {
throw new Error(
`CalendarDate Validation Error: Input month ${this.month} is not valid. Month must be a number between 1 and 12.`,
);
}
const maxDayOfMonth = CalendarDate.getMaxDayOfMonth(this.year, this.month);
if (this.day < 1) {
throw new Error(
`CalendarDate Validation Error: Input day ${this.day} is not valid. Day must be a number greater than 0.`,
);
}
if (this.day > maxDayOfMonth) {
throw new Error(
`CalendarDate Validation Error: Input date ${this.year}-${this.month}-${this.day} is not a valid calendar date.`,
);
}
const date = new Date(`${this.toString()}T00:00:00.000Z`);
this.unixTimestampInSeconds = date.getTime() / 1000;
this.weekday = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
Object.freeze(this);
}
public static isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
public static getMaxDayOfMonth(year: number, month: number): number {
return month === 2 && CalendarDate.isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
}
private static getIntlDateTimeFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = CalendarDate.dateTimeFormatterByTimezone.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone,
});
CalendarDate.dateTimeFormatterByTimezone.set(timeZone, formatter);
}
return formatter;
}
/**
* returns a CalendarDate instance for the supplied Date, using UTC values
*/
static fromDateUTC(date: Date): CalendarDate {
return new CalendarDate(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
}
/**
* returns a CalendarDate instance for the supplied Date, using local time zone values
*/
static fromDateLocal(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
/**
* returns a CalendarDate instance for the supplied Date, using the supplied time zone string
*/
static fromDateWithTimeZone(date: Date, timeZone: string): CalendarDate {
return new CalendarDate(
CalendarDate.getIntlDateTimeFormatter(timeZone).format(date).padStart(10, '0'),
);
}
/**
* returns a CalendarDate instance for the current UTC Date
*/
static nowUTC(): CalendarDate {
return CalendarDate.fromDateUTC(new Date());
}
/**
* returns a CalendarDate instance for the current Date using the local time zone of your environment
*/
static nowLocal(): CalendarDate {
return CalendarDate.fromDateLocal(new Date());
}
/**
* returns a CalendarDate instance for the current Date using the supplied time zone string
*/
static nowTimeZone(timeZone: string): CalendarDate {
return CalendarDate.fromDateWithTimeZone(new Date(), timeZone);
}
/**
*
* @param isoString pattern YYYY-MM-DD
*/
static parse(isoString: string): CalendarDate {
const { year, month, day } = parseIsoString(isoString);
return new CalendarDate(year, month, day);
}
static max(...values: CalendarDate[]): CalendarDate {
if (!values.length) {
throw new Error(
'CalendarDate.max Validation Error: Function max requires at least one input argument.',
);
}
return values.reduce(
(maxValue, currentValue) => (currentValue > maxValue ? currentValue : maxValue),
values[0],
);
}
static min(...values: CalendarDate[]): CalendarDate {
if (!values.length) {
throw new Error(
'CalendarDate.min Validation Error: Function min requires at least one input argument.',
);
}
return values.reduce(
(minValue, currentValue) => (currentValue < minValue ? currentValue : minValue),
values[0],
);
}
/**
* Returns a copy of the supplied array of CalendarDates sorted ascending
*/
static sortAscending(calendarDates: CalendarDate[]): CalendarDate[] {
return [...calendarDates].sort((a, b) => a.valueOf() - b.valueOf());
}
/**
* Returns a copy of the supplied array of CalendarDates sorted descending
*/
static sortDescending(calendarDates: CalendarDate[]): CalendarDate[] {
return [...calendarDates].sort((a, b) => b.valueOf() - a.valueOf());
}
/**
* Returns the ISO string representation yyyy-MM-dd
*/
toString(): string {
return `${this.year.toString().padStart(4, '0')}-${this.month
.toString()
.padStart(2, '0')}-${this.day.toString().padStart(2, '0')}`;
}
/**
* Returns a string representation formatted according to the specified format string.
* Supports the following Tokens:
*
* - **yyyy**: four digit year
* - **yy**: two digit year
* - **y**: year without padding
* - **MM**: month padded to 2 digits
* - **M**: month without padding
* - **dd**: day padded to 2 digits
* - **d**: day without padding
*
*/
toFormat(pattern: string): string;
/**
* Returns a string representation formatted with the Intl.DateTimeFormat API.
*/
toFormat(
locales: string,
options: Pick<Intl.DateTimeFormatOptions, 'year' | 'month' | 'day' | 'weekday'>,
): string;
toFormat(
input: string,
options?: Pick<Intl.DateTimeFormatOptions, 'year' | 'month' | 'day' | 'weekday'>,
): string {
if (options) {
const formatter = new Intl.DateTimeFormat(input, options);
return formatter.format(new Date(this.year, this.month - 1, this.day));
} else {
return input
.replace(/yyyy/g, this.year.toString().padStart(4, '0'))
.replace(/yy/g, this.year.toString().slice(-2).padStart(2, '0'))
.replace(/y/g, this.year.toString())
.replace(/MM/g, this.month.toString().padStart(2, '0'))
.replace(/M/g, this.month.toString())
.replace(/dd/g, this.day.toString().padStart(2, '0'))
.replace(/d/g, this.day.toString());
}
}
/**
* Used by JSON stringify method.
*/
toJSON(): string {
return this.toString();
}
toDateUTC(): Date {
return new Date(this.unixTimestampInSeconds * 1000);
}
/**
* @deprecated use toDateUTC instead. This method will be removed in the next major version.
*/
toDate(): Date {
return this.toDateUTC();
}
toDateLocal(): Date {
return new Date(this.year, this.month - 1, this.day);
}
/**
* Returns the unix timestamp in seconds.
*/
valueOf(): number {
return this.unixTimestampInSeconds;
}
equals(calendarDate: CalendarDate): boolean {
return this.valueOf() === calendarDate.valueOf();
}
/**
* Checks if the month and year of this CalendarDate are the same as the month and year of the supplied CalendarDate.
*/
isSameMonth(calendarDate: CalendarDate): boolean {
return this.year === calendarDate.year && this.month === calendarDate.month;
}
/**
* Checks if the year of this CalendarDate is the same as the year of the supplied CalendarDate.
*/
isSameYear(calendarDate: CalendarDate): boolean {
return this.year === calendarDate.year;
}
isBefore(calendarDate: CalendarDate): boolean {
return this.valueOf() < calendarDate.valueOf();
}
isAfter(calendarDate: CalendarDate): boolean {
return this.valueOf() > calendarDate.valueOf();
}
isBeforeOrEqual(calendarDate: CalendarDate): boolean {
return this.valueOf() <= calendarDate.valueOf();
}
isAfterOrEqual(calendarDate: CalendarDate): boolean {
return this.valueOf() >= calendarDate.valueOf();
}
/**
* Returns a new CalendarDate with the specified amount of months added.
*
* @param amount
* @param enforceEndOfMonth If set to true the addition will never cause an overflow to the next month.
*/
addMonths(amount: number, enforceEndOfMonth = false): CalendarDate {
const totalMonths = this.month + amount - 1;
let year = this.year + Math.floor(totalMonths / 12);
let month = totalMonths % 12;
let day = this.day;
if (month < 0) {
month = (month + 12) % 12;
}
const maxDayOfMonth = CalendarDate.getMaxDayOfMonth(year, month + 1);
if (enforceEndOfMonth) {
day = Math.min(maxDayOfMonth, day);
} else if (day > maxDayOfMonth) {
day = day - maxDayOfMonth;
year = year + Math.floor((month + 1) / 12);
month = (month + 1) % 12;
}
return new CalendarDate(year, month + 1, day);
}
/**
* Returns a new CalendarDate with the specified amount of days added.
* Allows overflow.
*/
addDays(amount: number): CalendarDate {
return CalendarDate.parse(
new Date((this.valueOf() + DAY_IN_SECONDS * amount) * 1000).toISOString().slice(0, 10),
);
}
getLastDayOfMonth(): CalendarDate {
return new CalendarDate(
this.year,
this.month,
CalendarDate.getMaxDayOfMonth(this.year, this.month),
);
}
getFirstDayOfMonth(): CalendarDate {
return new CalendarDate(this.year, this.month, 1);
}
isFirstDayOfMonth(): boolean {
return this.day === 1;
}
isLastDayOfMonth(): boolean {
return this.day === CalendarDate.getMaxDayOfMonth(this.year, this.month);
}
/**
* returns the last day (sunday) of the week of this calendar date as a new calendar date object.
*/
getLastDayOfWeek(): CalendarDate {
return this.addDays(7 - this.weekday);
}
/**
* returns the first day (monday) of the week of this calendar date as a new calendar date object.
*/
getFirstDayOfWeek(): CalendarDate {
return this.addDays(-(this.weekday - 1));
}
/**
* returns true if the weekday is monday(1)
*/
isFirstDayOfWeek(): boolean {
return this.weekday === 1;
}
/**
* returns true if the weekday is sunday(7)
*/
isLastDayOfWeek(): boolean {
return this.weekday === 7;
}
/**
* subtracts the input CalendarDate from this CalendarDate and returns the difference in days
*/
getDifferenceInDays(calendarDate: CalendarDate, absolute?: boolean): number {
const days = (this.valueOf() - calendarDate.valueOf()) / DAY_IN_SECONDS;
return absolute ? Math.abs(days) : days;
}
/**
* The number of the week of the year that the day is in. By definition (ISO 8601), the first week of a year contains January 4 of that year.
* (The ISO-8601 week starts on Monday.) In other words, the first Thursday of a year is in week 1 of that year. (for timestamp values only).
* [source: https://www.postgresql.org/docs/8.1/functions-datetime.html]
*/
public get week(): number {
return CalendarDate.getWeekNumber(this.year, this.month, this.day);
}
/**
* Source: https://weeknumber.com/how-to/javascript
*/
private static getWeekNumber(year: number, month: number, day: number): number {
const date = new Date(year, month - 1, day);
date.setHours(0, 0, 0, 0);
// Thursday in current week decides the year.
date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
// January 4 is always in week 1.
const week1 = new Date(date.getFullYear(), 0, 4);
// Adjust to Thursday in week 1 and count number of weeks from date to week1.
return (
1 +
Math.round(
((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7,
)
);
}
/**
* The quarter of the year (1-4) based on the month of the date.
*
* Quarter breakdown:
* - 1st Quarter: January to March (1-3)
* - 2nd Quarter: April to June (4-6)
* - 3rd Quarter: July to September (7-9)
* - 4th Quarter: October to December (10-12)
*/
public get quarter(): number {
return Math.floor((this.month - 1) / 3) + 1;
}
}