modules/timezone.js

import { OzTime } from '../core/core.js';

/**
 * Модуль для работы с часовыми поясами.
 *
 * @module modules/timezone
 */

/**
 * Проверяет корректность идентификатора часового пояса.
 *
 * @private
 * @param {string} timezone - Идентификатор часового пояса в формате IANA.
 * @throws {TypeError} Выбрасывается, если timezone пустой или не является строкой.
 * @throws {Error} Выбрасывается, если timezone не поддерживается.
 * @returns {void}
 */
function validateTimezone(timezone) {
    if (typeof timezone !== 'string' || timezone.trim() === '') {
        throw new TypeError('setTimezone: timezone must be a non-empty string');
    }

    if (!Intl.supportedValuesOf('timeZone').includes(timezone)) {
        throw new Error(`Unsupported timezone: ${timezone}`);
    }
}

/**
 * Вычисляет смещение часового пояса относительно UTC для конкретного timestamp.
 *
 * @private
 * @param {number} timestamp - Unix timestamp в миллисекундах.
 * @param {string} timeZone - Часовой пояс в формате IANA.
 * @returns {number} Смещение в минутах относительно UTC.
 */
function getOffsetMinutesFor(timestamp, timeZone) {
    const date = new Date(timestamp);

    const formatter = new Intl.DateTimeFormat('en-US', {
        timeZone,
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: false,
    });

    const parts = formatter.formatToParts(date);
    const lookup = Object.fromEntries(parts.map((p) => [p.type, p.value]));

    const year = Number(lookup.year);
    const month = Number(lookup.month);
    const day = Number(lookup.day);
    const hour = Number(lookup.hour);
    const minute = Number(lookup.minute);
    const second = Number(lookup.second);

    const utcTimestamp = Date.UTC(year, month - 1, day, hour, minute, second);

    return (utcTimestamp - timestamp) / 60000;
}

/**
 * Возвращает новый экземпляр {@link OzTime} с тем же timestamp и locale,
 * но с другим часовым поясом.
 *
 * Абсолютный момент времени при этом не изменяется.
 *
 * @param {OzTime} time - Исходный экземпляр {@link OzTime}.
 * @param {string} timezone - Новый часовой пояс в формате IANA.
 * @throws {TypeError} Выбрасывается, если первый аргумент не является экземпляром OzTime или timezone некорректен.
 * @throws {Error} Выбрасывается, если timezone не поддерживается.
 * @returns {OzTime} Новый экземпляр OzTime с другим часовым поясом.
 * @example
 * import { setTimezone, fromISO } from '@alexstukovnikov/oz-time';
 *
 * const time = fromISO('2024-05-25T12:00:00Z', 'UTC', 'ru-RU');
 * const moscow = setTimezone(time, 'Europe/Moscow');
 * console.log(moscow.getTimezone()); // ожидаемый результат: Europe/Moscow
 */
export function setTimezone(time, timezone) {
    if (!(time instanceof OzTime)) {
        throw new TypeError('tz: first argument must be OzTime');
    }

    validateTimezone(timezone);

    return new OzTime(time.getTimestamp(), timezone, time.getLocale());
}

/**
 * Возвращает смещение часового пояса экземпляра относительно UTC в минутах.
 *
 * @param {OzTime} time - Экземпляр времени.
 * @throws {TypeError} Выбрасывается, если аргумент не является экземпляром OzTime.
 * @returns {number} Смещение в минутах относительно UTC.
 * @example
 * import { getTimezoneOffset, fromISO } from '@alexstukovnikov/oz-time';
 *
 * const time = fromISO('2024-05-25T12:00:00Z', 'Europe/Moscow', 'ru-RU');
 * console.log(getTimezoneOffset(time)); // ожидаемый результат: 180
 */
export function getTimezoneOffset(time) {
    if (!(time instanceof OzTime)) {
        throw new TypeError('getTimezoneOffset: argument must be OzTime');
    }
    const timestamp = time.getTimestamp();
    const timeZone = time.getTimezone();
    return getOffsetMinutesFor(timestamp, timeZone);
}