UNPKG

universal-common

Version:

Library that provides useful missing base class library functionality.

797 lines (725 loc) 30.4 kB
import DateTime from "./DateTime.js"; import TimeOnly from "./TimeOnly.js"; /** * Represents dates with values ranging from January 1, 0001 Anno Domini (Common Era) * through December 31, 9999 A.D. (C.E.) in the Gregorian calendar. * * DateOnly provides date-only functionality without time components, making it ideal * for scenarios where only the date portion is relevant (birthdays, deadlines, etc.). * * @example * // Create DateOnly instances * const today = new DateOnly(2024, 12, 25); * const fromDayNumber = DateOnly.fromDayNumber(738500); * const fromDate = DateOnly.fromDate(new Date()); * * // Date arithmetic * const tomorrow = today.addDays(1); * const nextMonth = today.addMonths(1); * const nextYear = today.addYears(1); * * // Comparisons * console.log(today.equals(tomorrow)); // false * console.log(today.compareTo(tomorrow)); // -1 * * // Formatting * console.log(today.toString()); // "2024-12-25" */ export default class DateOnly { /** @private */ #dayNumber; /** @private */ static #MIN_DAY_NUMBER = 0; /** @private */ static #MAX_DAY_NUMBER = 3652058; // December 31, 9999 /** @private */ static #TICKS_PER_DAY = 864000000000; // 10,000 ticks per millisecond * 1000 ms/s * 60 s/min * 60 min/hr * 24 hr/day /** * Creates a new DateOnly instance. * * @param {number} year - The year (1 through 9999) * @param {number} month - The month (1 through 12) * @param {number} day - The day (1 through the number of days in the specified month) * @throws {RangeError} If any parameter is out of valid range * @throws {RangeError} If the day is invalid for the specified month and year * * @example * const date = new DateOnly(2024, 12, 25); // December 25, 2024 * const leapDay = new DateOnly(2024, 2, 29); // Valid leap year date */ constructor(year, month, day) { // Validate input parameters if (year < 1 || year > 9999) { throw new RangeError("Year must be between 1 and 9999"); } if (month < 1 || month > 12) { throw new RangeError("Month must be between 1 and 12"); } if (day < 1) { throw new RangeError("Day must be at least 1"); } // Validate day for the given month and year const daysInMonth = DateOnly.#getDaysInMonth(year, month); if (day > daysInMonth) { throw new RangeError(`Day must be between 1 and ${daysInMonth} for ${year}-${month}`); } this.#dayNumber = DateOnly.#dateToTicks(year, month, day); } /** * Private constructor that creates DateOnly from day number directly. * @private * @param {number} dayNumber - The day number (0 to MAX_DAY_NUMBER) */ static #fromDayNumber(dayNumber) { const instance = DateOnly.fromDateTime(DateTime.today); instance.#dayNumber = dayNumber; return instance; } /** * Creates a DateOnly instance from the specified day number. * * @param {number} dayNumber - The number of days since January 1, 0001 in the Proleptic Gregorian calendar * @returns {DateOnly} A new DateOnly instance representing the specified day * @throws {RangeError} If dayNumber is out of valid range (0 to 3652058) * * @example * const date = DateOnly.fromDayNumber(738500); // Represents a specific date * console.log(date.toString()); // Outputs the corresponding date string */ static fromDayNumber(dayNumber) { if (!Number.isInteger(dayNumber) || dayNumber < DateOnly.#MIN_DAY_NUMBER || dayNumber > DateOnly.#MAX_DAY_NUMBER) { throw new RangeError(`Day number must be an integer between ${DateOnly.#MIN_DAY_NUMBER} and ${DateOnly.#MAX_DAY_NUMBER}`); } return DateOnly.#fromDayNumber(dayNumber); } /** * Converts date components to day number. * * @private * @param {number} year - The year * @param {number} month - The month * @param {number} day - The day * @returns {number} The day number representing the date */ static #dateToTicks(year, month, day) { // Calculate days from year 1 to the beginning of the specified year const daysToYear = DateOnly.#daysToYear(year); // Calculate days from beginning of year to beginning of month const daysToMonth = DateOnly.#getDaysToMonth(year, month); // Add the day (subtract 1 because day is 1-based) return daysToYear + daysToMonth + day - 1; } /** * Calculates the number of days from year 1 to the beginning of the specified year. * * @private * @param {number} year - The year * @returns {number} Number of days from year 1 to the start of the specified year */ static #daysToYear(year) { const y = year - 1; return (y * 365) + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400); } /** * Gets the number of days in the specified month. * * @private * @param {number} year - The year * @param {number} month - The month (1-12) * @returns {number} Number of days in the month */ static #getDaysInMonth(year, month) { const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (month === 2 && DateOnly.isLeapYear(year)) { return 29; } return daysInMonth[month - 1]; } /** * Gets the number of days from the beginning of the year to the beginning of the specified month. * * @private * @param {number} year - The year * @param {number} month - The month (1-12) * @returns {number} Number of days from beginning of year to beginning of month */ static #getDaysToMonth(year, month) { const daysToMonth365 = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; const daysToMonth366 = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; const daysToMonth = DateOnly.isLeapYear(year) ? daysToMonth366 : daysToMonth365; return daysToMonth[month - 1]; } /** * Converts day number back to year, month, day components. * This is used internally for property getters. * * @private * @param {number} dayNumber - The day number * @returns {{year: number, month: number, day: number}} Date components */ static #dayNumberToDate(dayNumber) { // Start with an approximation of the year let year = Math.floor(dayNumber / 365.25) + 1; // Adjust the year if our approximation is off while (DateOnly.#daysToYear(year) > dayNumber) { year--; } while (DateOnly.#daysToYear(year + 1) <= dayNumber) { year++; } // Calculate remaining days in the year const dayOfYear = dayNumber - DateOnly.#daysToYear(year) + 1; // Find the month let month = 1; while (month <= 12) { const daysToMonth = DateOnly.#getDaysToMonth(year, month); const daysToNextMonth = month === 12 ? (DateOnly.isLeapYear(year) ? 366 : 365) : DateOnly.#getDaysToMonth(year, month + 1); if (dayOfYear > daysToMonth && dayOfYear <= daysToNextMonth) { break; } month++; } // Calculate the day const daysToMonth = DateOnly.#getDaysToMonth(year, month); const day = dayOfYear - daysToMonth; return { year, month, day }; } /** * Determines whether the specified year is a leap year. * * A leap year is divisible by 4, except for years divisible by 100 unless they are also divisible by 400. * * @param {number} year - The year to check (1 through 9999) * @returns {boolean} true if the year is a leap year; otherwise, false * @throws {RangeError} If year is out of valid range * * @example * DateOnly.isLeapYear(2024); // true (divisible by 4) * DateOnly.isLeapYear(1900); // false (divisible by 100 but not 400) * DateOnly.isLeapYear(2000); // true (divisible by 400) */ static isLeapYear(year) { if (year < 1 || year > 9999) { throw new RangeError("Year must be between 1 and 9999"); } return (year % 4 === 0) && (year % 100 !== 0 || year % 400 === 0); } /** * Gets the year component of the date represented by this instance. * * @type {number} * @readonly * @example * const date = new DateOnly(2024, 12, 25); * console.log(date.year); // 2024 */ get year() { return DateOnly.#dayNumberToDate(this.#dayNumber).year; } /** * Gets the month component of the date represented by this instance. * * @type {number} * @readonly * @example * const date = new DateOnly(2024, 12, 25); * console.log(date.month); // 12 */ get month() { return DateOnly.#dayNumberToDate(this.#dayNumber).month; } /** * Gets the day component of the date represented by this instance. * * @type {number} * @readonly * @example * const date = new DateOnly(2024, 12, 25); * console.log(date.day); // 25 */ get day() { return DateOnly.#dayNumberToDate(this.#dayNumber).day; } /** * Gets the day of the week represented by this instance. * * @type {number} * @readonly * @example * const date = new DateOnly(2024, 12, 25); * console.log(date.dayOfWeek); // 3 (Wednesday, where Sunday = 0) */ get dayOfWeek() { // January 1, 0001 was a Monday (day 1), so we adjust accordingly // Day number 0 = Monday (1), so (0 + 1) % 7 = 1 return (this.#dayNumber + 1) % 7; } /** * Gets the day of the year represented by this instance. * * @type {number} * @readonly * @example * const date = new DateOnly(2024, 12, 25); * console.log(date.dayOfYear); // 360 (the 360th day of 2024) */ get dayOfYear() { const { year } = DateOnly.#dayNumberToDate(this.#dayNumber); const daysToYear = DateOnly.#daysToYear(year); return this.#dayNumber - daysToYear + 1; } /** * Gets the number of days since January 1, 0001 in the Proleptic Gregorian calendar * represented by this instance. * * @type {number} * @readonly * @example * const date = new DateOnly(2024, 1, 1); * console.log(date.dayNumber); // Number of days since year 1 */ get dayNumber() { return this.#dayNumber; } /** * Adds the specified number of days to the value of this instance. * * @param {number} value - The number of days to add. Can be negative to subtract days * @returns {DateOnly} A new DateOnly instance with the specified number of days added * @throws {RangeError} If the resulting date would be out of valid range * @throws {TypeError} If value is not a number * * @example * const date = new DateOnly(2024, 12, 25); * const nextDay = date.addDays(1); // December 26, 2024 * const prevDay = date.addDays(-1); // December 24, 2024 * const nextWeek = date.addDays(7); // January 1, 2025 */ addDays(value) { if (typeof value !== 'number' || !Number.isInteger(value)) { throw new TypeError("Value must be an integer"); } const newDayNumber = this.#dayNumber + value; if (newDayNumber < DateOnly.#MIN_DAY_NUMBER || newDayNumber > DateOnly.#MAX_DAY_NUMBER) { throw new RangeError("Resulting date is out of valid range"); } return DateOnly.#fromDayNumber(newDayNumber); } /** * Adds the specified number of months to the value of this instance. * * If the resulting day is invalid for the target month (e.g., adding 1 month to January 31 * would result in February 31), the day is adjusted to the last valid day of that month. * * @param {number} months - The number of months to add. Can be negative to subtract months * @returns {DateOnly} A new DateOnly instance with the specified number of months added * @throws {RangeError} If months parameter is out of range (-120000 to 120000) * @throws {RangeError} If the resulting year would be out of valid range * @throws {TypeError} If months is not a number * * @example * const date = new DateOnly(2024, 1, 31); * const nextMonth = date.addMonths(1); // February 29, 2024 (adjusted from Feb 31) * const prevMonth = date.addMonths(-1); // December 31, 2023 * const nextYear = date.addMonths(12); // January 31, 2025 */ addMonths(months) { if (typeof months !== 'number' || !Number.isInteger(months)) { throw new TypeError("Months must be an integer"); } if (months < -120000 || months > 120000) { throw new RangeError("Months must be between -120000 and 120000"); } let { year, month, day } = DateOnly.#dayNumberToDate(this.#dayNumber); // Calculate new year and month const totalMonths = year * 12 + (month - 1) + months; const newYear = Math.floor(totalMonths / 12); const newMonth = (totalMonths % 12) + 1; if (newYear < 1 || newYear > 9999) { throw new RangeError("Resulting year is out of valid range (1-9999)"); } // Adjust day if it's invalid for the new month const daysInNewMonth = DateOnly.#getDaysInMonth(newYear, newMonth); const newDay = Math.min(day, daysInNewMonth); return new DateOnly(newYear, newMonth, newDay); } /** * Adds the specified number of years to the value of this instance. * * If the current date is February 29 (leap day) and the target year is not a leap year, * the resulting date will be February 28. * * @param {number} years - The number of years to add. Can be negative to subtract years * @returns {DateOnly} A new DateOnly instance with the specified number of years added * @throws {RangeError} If years parameter is out of range (-10000 to 10000) * @throws {RangeError} If the resulting year would be out of valid range (1 to 9999) * @throws {TypeError} If years is not a number * * @example * const date = new DateOnly(2024, 2, 29); // Leap day * const nextYear = date.addYears(1); // February 28, 2025 (not a leap year) * const prevYear = date.addYears(-1); // February 28, 2023 (not a leap year) * const nextLeapYear = date.addYears(4); // February 29, 2028 (leap year) */ addYears(years) { if (typeof years !== 'number' || !Number.isInteger(years)) { throw new TypeError("Years must be an integer"); } if (years < -10000 || years > 10000) { throw new RangeError("Years must be between -10000 and 10000"); } let { year, month, day } = DateOnly.#dayNumberToDate(this.#dayNumber); const newYear = year + years; if (newYear < 1 || newYear > 9999) { throw new RangeError("Resulting year is out of valid range (1-9999)"); } // Handle leap day edge case if (month === 2 && day === 29 && !DateOnly.isLeapYear(newYear)) { day = 28; } return new DateOnly(newYear, month, day); } /** * Determines whether this DateOnly instance is equal to another DateOnly instance. * * @param {DateOnly} other - The DateOnly instance to compare with * @returns {boolean} true if both instances represent the same date; otherwise, false * * @example * const date1 = new DateOnly(2024, 12, 25); * const date2 = new DateOnly(2024, 12, 25); * const date3 = new DateOnly(2024, 12, 26); * * console.log(date1.equals(date2)); // true * console.log(date1.equals(date3)); // false */ equals(other) { return other instanceof DateOnly && this.#dayNumber === other.#dayNumber; } /** * Compares this DateOnly instance to another DateOnly instance. * * @param {DateOnly} other - The DateOnly instance to compare with * @returns {number} * - Less than 0 if this instance is earlier than other * - 0 if this instance is equal to other * - Greater than 0 if this instance is later than other * @throws {TypeError} If other is not a DateOnly instance * * @example * const date1 = new DateOnly(2024, 12, 24); * const date2 = new DateOnly(2024, 12, 25); * const date3 = new DateOnly(2024, 12, 26); * * console.log(date1.compareTo(date2)); // -1 (date1 is earlier) * console.log(date2.compareTo(date2)); // 0 (same date) * console.log(date3.compareTo(date2)); // 1 (date3 is later) */ compareTo(other) { if (!(other instanceof DateOnly)) { throw new TypeError("Argument must be a DateOnly instance"); } return this.#dayNumber - other.#dayNumber; } /** * Comparison operators as methods (since JavaScript doesn't support operator overloading) */ /** * Returns true if this date is less than the other date. * @param {DateOnly} other - The date to compare with * @returns {boolean} */ isLessThan(other) { return this.compareTo(other) < 0; } /** * Returns true if this date is less than or equal to the other date. * @param {DateOnly} other - The date to compare with * @returns {boolean} */ isLessThanOrEqual(other) { return this.compareTo(other) <= 0; } /** * Returns true if this date is greater than the other date. * @param {DateOnly} other - The date to compare with * @returns {boolean} */ isGreaterThan(other) { return this.compareTo(other) > 0; } /** * Returns true if this date is greater than or equal to the other date. * @param {DateOnly} other - The date to compare with * @returns {boolean} */ isGreaterThanOrEqual(other) { return this.compareTo(other) >= 0; } /** * Converts the DateOnly to its string representation in ISO 8601 format (YYYY-MM-DD). * * @returns {string} A string representation of the date in YYYY-MM-DD format * * @example * const date = new DateOnly(2024, 12, 25); * console.log(date.toString()); // "2024-12-25" * * const date2 = new DateOnly(2024, 1, 5); * console.log(date2.toString()); // "2024-01-05" */ toString() { const { year, month, day } = DateOnly.#dayNumberToDate(this.#dayNumber); return `${year.toString().padStart(4, "0")}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`; } /** * Creates a DateOnly instance from a JavaScript Date object. * * Only the date components (year, month, day) are used; time components are ignored. * * @param {Date} date - The JavaScript Date object to convert * @returns {DateOnly} A new DateOnly instance representing the date portion of the input Date * @throws {TypeError} If the input is not a Date object * * @example * const jsDate = new Date(2024, 11, 25, 15, 30, 45); // December 25, 2024 3:30:45 PM * const dateOnly = DateOnly.fromDate(jsDate); * console.log(dateOnly.toString()); // "2024-12-25" (time ignored) */ static fromDate(date) { if (!(date instanceof Date)) { throw new TypeError("Argument must be a Date instance"); } if (isNaN(date.getTime())) { throw new TypeError("Date instance must be valid"); } return new DateOnly(date.getFullYear(), date.getMonth() + 1, date.getDate()); } /** * Creates a DateOnly instance from a DateTime object. * * Only the date components are extracted; time components are ignored. * * @param {DateTime} dateTime - The DateTime object to convert * @returns {DateOnly} A new DateOnly instance representing the date portion of the DateTime * @throws {TypeError} If the input is not a DateTime object * * @example * // Assuming DateTime is imported * const dateTime = new DateTime(2024, 12, 25, 15, 30, 45); * const dateOnly = DateOnly.fromDateTime(dateTime); * console.log(dateOnly.toString()); // "2024-12-25" */ static fromDateTime(dateTime) { // Check if DateTime is available and if the parameter is a DateTime instance if (typeof DateTime !== 'undefined' && dateTime instanceof DateTime) { return new DateOnly(dateTime.year, dateTime.month, dateTime.day); } // Fallback check for duck typing (object has year, month, day properties) if (dateTime && typeof dateTime.year === 'number' && typeof dateTime.month === 'number' && typeof dateTime.day === 'number') { return new DateOnly(dateTime.year, dateTime.month, dateTime.day); } throw new TypeError("Argument must be a DateTime instance"); } /** * Combines this DateOnly with a time to create a JavaScript Date object. * * @param {number} [hours=0] - The hour component (0-23) * @param {number} [minutes=0] - The minute component (0-59) * @param {number} [seconds=0] - The second component (0-59) * @param {number} [milliseconds=0] - The millisecond component (0-999) * @param {boolean} [useUtc=false] - Whether to create a UTC date or local date * @returns {Date} A JavaScript Date object combining this date with the specified time * * @example * const dateOnly = new DateOnly(2024, 12, 25); * const localDate = dateOnly.toDate(10, 30, 0); // 10:30 AM local time * const utcDate = dateOnly.toDate(10, 30, 0, 0, true); // 10:30 AM UTC */ toDate(hours = 0, minutes = 0, seconds = 0, milliseconds = 0, useUtc = false) { const { year, month, day } = DateOnly.#dayNumberToDate(this.#dayNumber); if (useUtc) { return new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds, milliseconds)); } else { return new Date(year, month - 1, day, hours, minutes, seconds, milliseconds); } } /** * Combines this DateOnly with a TimeOnly to create a DateTime object. * * @param {TimeOnly} time - The TimeOnly instance to combine with this date * @param {number} [kind] - Optional DateTimeKind for the resulting DateTime * @returns {DateTime} A DateTime object combining this date with the specified time * @throws {TypeError} If time is not a TimeOnly instance * * @example * // Assuming TimeOnly and DateTime are available * const dateOnly = new DateOnly(2024, 12, 25); * const timeOnly = new TimeOnly(10, 30, 0); * const dateTime = dateOnly.toDateTime(timeOnly); * console.log(dateTime.toString()); // "2024-12-25 10:30:00" */ toDateTime(time, kind) { // Check if the required classes are available if (typeof TimeOnly !== 'undefined' && typeof DateTime !== 'undefined') { if (!(time instanceof TimeOnly)) { throw new TypeError("Argument must be a TimeOnly instance"); } const { year, month, day } = DateOnly.#dayNumberToDate(this.#dayNumber); if (kind !== undefined) { return new DateTime(year, month, day, time.hour, time.minute, time.second, time.millisecond, kind); } else { return new DateTime(year, month, day, time.hour, time.minute, time.second, time.millisecond); } } // Fallback for duck typing if (time && typeof time.hour === 'number' && typeof time.minute === 'number' && typeof time.second === 'number') { const millisecond = time.millisecond || 0; const { year, month, day } = DateOnly.#dayNumberToDate(this.#dayNumber); // Try to create DateTime if available if (typeof DateTime !== 'undefined') { if (kind !== undefined) { return new DateTime(year, month, day, time.hour, time.minute, time.second, millisecond, kind); } else { return new DateTime(year, month, day, time.hour, time.minute, time.second, millisecond); } } } throw new TypeError("TimeOnly and DateTime classes must be available, or argument must be a TimeOnly instance"); } /** * Gets the earliest possible DateOnly value (January 1, 0001). * * @type {DateOnly} * @readonly * @static * @example * const minDate = DateOnly.minValue; * console.log(minDate.toString()); // "0001-01-01" */ static get minValue() { return DateOnly.#fromDayNumber(DateOnly.#MIN_DAY_NUMBER); } /** * Gets the latest possible DateOnly value (December 31, 9999). * * @type {DateOnly} * @readonly * @static * @example * const maxDate = DateOnly.maxValue; * console.log(maxDate.toString()); // "9999-12-31" */ static get maxValue() { return DateOnly.#fromDayNumber(DateOnly.#MAX_DAY_NUMBER); } /** * Compares two DateOnly instances. * * @param {DateOnly} d1 - The first DateOnly instance * @param {DateOnly} d2 - The second DateOnly instance * @returns {number} * - Less than 0 if d1 is earlier than d2 * - 0 if d1 equals d2 * - Greater than 0 if d1 is later than d2 * * @example * const date1 = new DateOnly(2024, 12, 24); * const date2 = new DateOnly(2024, 12, 25); * * console.log(DateOnly.compare(date1, date2)); // -1 * console.log(DateOnly.compare(date2, date1)); // 1 * console.log(DateOnly.compare(date1, date1)); // 0 */ static compare(d1, d2) { if (!(d1 instanceof DateOnly) || !(d2 instanceof DateOnly)) { throw new TypeError("Both arguments must be DateOnly instances"); } return d1.#dayNumber - d2.#dayNumber; } /** * Returns the primitive value of the DateOnly instance (day number). * * This method is called automatically when the DateOnly is used in contexts * that require a primitive value, such as arithmetic operations or comparisons. * * @returns {number} The day number representing this date * * @example * const date = new DateOnly(2024, 12, 25); * console.log(+date); // Outputs the day number * console.log(date.valueOf()); // Same as above */ valueOf() { return this.#dayNumber; } /** * Parses a string representation of a date and returns a DateOnly instance. * * The string must be in ISO 8601 format (YYYY-MM-DD). * * @param {string} s - The string to parse * @returns {DateOnly} A DateOnly instance representing the parsed date * @throws {TypeError} If the input is not a string * @throws {Error} If the string format is invalid * @throws {RangeError} If the date components are out of valid range * * @example * const date1 = DateOnly.parse("2024-12-25"); * const date2 = DateOnly.parse("2024-02-29"); // Valid leap year date * * // These will throw errors: * // DateOnly.parse("25-12-2024"); // Wrong format * // DateOnly.parse("2024-13-01"); // Invalid month * // DateOnly.parse("2023-02-29"); // Invalid leap day */ static parse(s) { if (typeof s !== "string") { throw new TypeError("Input must be a string"); } const match = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { throw new Error("Invalid date format. Expected YYYY-MM-DD"); } const year = parseInt(match[1], 10); const month = parseInt(match[2], 10); const day = parseInt(match[3], 10); return new DateOnly(year, month, day); } /** * Attempts to parse a string representation of a date and returns the result. * * Unlike parse(), this method does not throw exceptions on invalid input. * * @param {string} s - The string to parse * @returns {{success: boolean, value: DateOnly|null}} * An object containing: * - success: true if parsing succeeded, false otherwise * - value: The parsed DateOnly instance if successful, null otherwise * * @example * const result1 = DateOnly.tryParse("2024-12-25"); * if (result1.success) { * console.log(result1.value.toString()); // "2024-12-25" * } * * const result2 = DateOnly.tryParse("invalid-date"); * console.log(result2.success); // false * console.log(result2.value); // null */ static tryParse(s) { try { return { success: true, value: DateOnly.parse(s) }; } catch { return { success: false, value: null }; } } }