utils/calendar.js

import { normalizeUnit, isFixedUnit, unitToMilliseconds } from './units.js';
import { OzTime } from '../core/core.js';

/**
 * Календарные утилиты для работы с датами, високосными годами
 * и разницей между значениями времени.
 *
 * @module utils/calendar
 */

/**
 * Проверяет корректность timestamp.
 *
 * @private
 * @param {number} timestamp - Unix timestamp в миллисекундах.
 * @param {string} name - Имя вызывающей функции.
 * @throws {TypeError} Выбрасывается, если timestamp некорректен.
 * @returns {void}
 */
function assertValidTimestamp(timestamp, name) {
    if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) {
        throw new TypeError(`${name}: timestamp must be a valid number`);
    }
}

/**
 * Проверяет корректность количества единиц времени.
 *
 * @private
 * @param {number} amount - Количество единиц времени.
 * @param {string} name - Имя вызывающей функции.
 * @throws {TypeError} Выбрасывается, если amount некорректен.
 * @returns {void}
 */
function assertValidAmount(amount, name) {
    if (typeof amount !== 'number' || Number.isNaN(amount)) {
        throw new TypeError(`${name}: amount must be a valid number`);
    }
}

/**
 * Проверяет, является ли значение экземпляром OzTime.
 *
 * @private
 * @param {*} value - Проверяемое значение.
 * @param {string} name - Имя параметра.
 * @throws {TypeError} Выбрасывается, если значение не является экземпляром OzTime.
 * @returns {void}
 */
function assertOzTime(value, name) {
    if (!(value instanceof OzTime)) {
        throw new TypeError(`${name} must be OzTime`);
    }
}

/**
 * Вычисляет календарную разницу в месяцах между двумя timestamp.
 *
 * @private
 * @param {number} leftTimestamp - Левый timestamp.
 * @param {number} rightTimestamp - Правый timestamp.
 * @returns {number} Разница в месяцах.
 */
function diffInMonths(leftTimestamp, rightTimestamp) {
    const left = new Date(leftTimestamp);
    const right = new Date(rightTimestamp);

    let months = (left.getUTCFullYear() - right.getUTCFullYear()) * 12 + (left.getUTCMonth() - right.getUTCMonth());

    const leftDay = left.getUTCDate();
    const rightDay = right.getUTCDate();

    if (months > 0 && leftDay < rightDay) {
        months -= 1;
    } else if (months < 0 && leftDay > rightDay) {
        months += 1;
    }

    return months;
}

/**
 * Вычисляет календарную разницу в годах между двумя timestamp.
 *
 * @private
 * @param {number} leftTimestamp - Левый timestamp.
 * @param {number} rightTimestamp - Правый timestamp.
 * @returns {number} Разница в годах.
 */
function diffInYears(leftTimestamp, rightTimestamp) {
    const left = new Date(leftTimestamp);
    const right = new Date(rightTimestamp);

    let years = left.getUTCFullYear() - right.getUTCFullYear();

    const leftMonth = left.getUTCMonth();
    const rightMonth = right.getUTCMonth();
    const leftDay = left.getUTCDate();
    const rightDay = right.getUTCDate();

    if (years > 0 && (leftMonth < rightMonth || (leftMonth === rightMonth && leftDay < rightDay))) {
        years -= 1;
    } else if (years < 0 && (leftMonth > rightMonth || (leftMonth === rightMonth && leftDay > rightDay))) {
        years += 1;
    }

    return years;
}

/**
 * Проверяет, является ли год високосным по григорианскому календарю.
 *
 * @param {number} year - Год.
 * @throws {TypeError} Выбрасывается, если year не является целым числом.
 * @returns {boolean} `true`, если год високосный.
 * @example
 * import { isLeapYear } from '@alexstukovnikov/oz-time';
 *
 * console.log(isLeapYear(2024)); // ожидаемый результат: true
 */
export function isLeapYear(year) {
    if (!Number.isInteger(year)) {
        throw new TypeError('isLeapYear: year must be an integer');
    }

    return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}

/**
 * Возвращает количество дней в указанном месяце указанного года.
 *
 * @param {number} year - Год.
 * @param {number} month - Месяц от 1 до 12.
 * @throws {TypeError} Выбрасывается, если year или month не являются целыми числами.
 * @throws {RangeError} Выбрасывается, если month вне диапазона от 1 до 12.
 * @returns {number} Количество дней в месяце.
 * @example
 * import { daysInMonth } from '@alexstukovnikov/oz-time';
 *
 * console.log(daysInMonth(2024, 2)); // ожидаемый результат: 29
 */
export function daysInMonth(year, month) {
    if (!Number.isInteger(year) || !Number.isInteger(month)) {
        throw new TypeError('daysInMonth: year and month must be integers');
    }

    if (month < 1 || month > 12) {
        throw new RangeError('daysInMonth: month must be between 1 and 12');
    }

    return new Date(Date.UTC(year, month, 0)).getUTCDate();
}

/**
 * Возвращает новый timestamp, увеличенный на указанное количество
 * фиксированных единиц времени.
 *
 * @param {number} timestamp - Unix timestamp в миллисекундах.
 * @param {number} amount - Количество единиц времени.
 * @param {string} unit - Фиксированная единица времени.
 * @throws {TypeError} Выбрасывается, если timestamp или amount некорректны.
 * @throws {Error} Выбрасывается, если unit не является фиксированной единицей.
 * @returns {number} Новый Unix timestamp в миллисекундах.
 * @example
 * import { addByFixedUnit } from './utils/calendar.js';
 *
 * console.log(addByFixedUnit(1716638400000, 1, 'day')); // ожидаемый результат: 1716724800000
 */
export function addByFixedUnit(timestamp, amount, unit) {
    assertValidTimestamp(timestamp, 'addByFixedUnit');
    assertValidAmount(amount, 'addByFixedUnit');

    const normalizedUnit = normalizeUnit(unit);

    if (!isFixedUnit(normalizedUnit)) {
        throw new Error(`addByFixedUnit does not support calendar unit: ${unit}`);
    }

    return timestamp + amount * unitToMilliseconds(normalizedUnit);
}

/**
 * Возвращает новый timestamp, увеличенный на указанное количество
 * календарных единиц времени.
 *
 * Поддерживаются только `month` и `year`.
 *
 * @param {number} timestamp - Unix timestamp в миллисекундах.
 * @param {number} amount - Количество единиц времени.
 * @param {string} unit - Календарная единица времени.
 * @throws {TypeError} Выбрасывается, если timestamp или amount некорректны.
 * @throws {Error} Выбрасывается, если unit не поддерживается.
 * @returns {number} Новый Unix timestamp в миллисекундах.
 * @example
 * import { addByCalendarUnit } from './utils/calendar.js';
 *
 * console.log(addByCalendarUnit(Date.UTC(2024, 0, 31, 0, 0, 0, 0), 1, 'month')); // ожидаемый результат: 1709164800000
 */
export function addByCalendarUnit(timestamp, amount, unit) {
    assertValidTimestamp(timestamp, 'addByCalendarUnit');
    assertValidAmount(amount, 'addByCalendarUnit');

    const normalizedUnit = normalizeUnit(unit);
    const date = new Date(timestamp);

    if (normalizedUnit === 'month') {
        const originalDay = date.getUTCDate();

        date.setUTCDate(1);
        date.setUTCMonth(date.getUTCMonth() + amount);

        const maxDay = daysInMonth(date.getUTCFullYear(), date.getUTCMonth() + 1);
        date.setUTCDate(Math.min(originalDay, maxDay));

        return date.getTime();
    }

    if (normalizedUnit === 'year') {
        const originalMonth = date.getUTCMonth();
        const originalDay = date.getUTCDate();

        date.setUTCDate(1);
        date.setUTCFullYear(date.getUTCFullYear() + amount);
        date.setUTCMonth(originalMonth);

        const maxDay = daysInMonth(date.getUTCFullYear(), originalMonth + 1);
        date.setUTCDate(Math.min(originalDay, maxDay));

        return date.getTime();
    }

    throw new Error(`addByCalendarUnit supports only month and year: ${unit}`);
}

/**
 * Возвращает числовую разницу между двумя экземплярами {@link OzTime}
 * в указанной единице времени.
 *
 * Для фиксированных единиц может возвращать дробное число,
 * для месяцев и лет возвращает целочисленную календарную разницу.
 *
 * @param {OzTime} left - Левое значение.
 * @param {OzTime} right - Правое значение.
 * @param {string} [unit='millisecond'] - Единица времени.
 * @throws {TypeError} Выбрасывается, если хотя бы один аргумент не является экземпляром OzTime.
 * @throws {Error} Выбрасывается, если unit не поддерживается.
 * @returns {number} Разница между двумя значениями времени.
 * @example
 * import { diff } from './utils/calendar.js';
 * import { fromISO } from '@alexstukovnikov/oz-time';
 *
 * const a = fromISO('2024-05-25T14:00:00Z');
 * const b = fromISO('2024-05-25T12:00:00Z');
 * console.log(diff(a, b, 'hour')); // ожидаемый результат: 2
 */
export function diff(left, right, unit = 'millisecond') {
    assertOzTime(left, 'left');
    assertOzTime(right, 'right');

    const normalizedUnit = normalizeUnit(unit);
    const leftTimestamp = left.getTimestamp();
    const rightTimestamp = right.getTimestamp();

    if (isFixedUnit(normalizedUnit)) {
        return (leftTimestamp - rightTimestamp) / unitToMilliseconds(normalizedUnit);
    }

    if (normalizedUnit === 'month') {
        return diffInMonths(leftTimestamp, rightTimestamp);
    }

    if (normalizedUnit === 'year') {
        return diffInYears(leftTimestamp, rightTimestamp);
    }

    throw new Error(`Unsupported unit: ${unit}`);
}