Учёт перехода на летнее время

Календарная арифметика в JavaScript сталкивается с фундаментальной проблемой: локальное время в разных регионах мира не является непрерывной математической шкалой. Изменения часовых поясов, а особенно переходы на летнее и зимнее время, создают разрывы и неоднозначности, которые нельзя корректно обработать с помощью стандартного объекта Date.

В момент перехода на летнее время часть локального времени «исчезает» (например, время с 02:00 до 03:00 может отсутствовать), а при возврате на зимнее — дублируется. Это приводит к двум типичным ситуациям:

  • Gap (разрыв времени) — локальное время не существует
  • Overlap (перекрытие) — локальное время существует в двух разных смещениях

Библиотека js-joda проектируется как строгая реализация ISO-8601 модели времени, где базовые сущности разделены на:

  • локальное время (LocalDate, LocalTime, LocalDateTime)
  • момент времени (Instant)
  • временную зону (ZoneId)
  • зональное время (ZonedDateTime)

Именно комбинация ZonedDateTime + ZoneId обеспечивает корректную работу с переходами времени.


Модель времени js-joda и её поведение при смене offset

В js-joda ключевым элементом учёта переходов является привязка локального времени к зоне:

import { ZonedDateTime, ZoneId } from '@js-joda/core';

Временная зона определяет правила смещения UTC, включая исторические изменения и DST-переходы.

const zone = ZoneId.of('Europe/Berlin');

Любая операция создания зонального времени проходит через проверку правил зоны:

const zdt = ZonedDateTime.of(2026, 3, 29, 2, 30, 0, 0, zone);

Если в указанной зоне существует переход на летнее время, то 02:30 может оказаться невалидным локальным временем.


Разрыв времени (Gap) и стратегия разрешения

Разрыв возникает, когда локальные часы «перепрыгивают» вперёд.

Пример: переход 02:00 → 03:00

В js-joda при создании несуществующего времени применяется политика нормализации:

  • смещение корректируется вперёд до ближайшего валидного момента
  • либо используется явное управление через ZoneOffsetTransition (внутренне)

Пример поведения:

const zone = ZoneId.of('Europe/Berlin');

const zdt = ZonedDateTime.of(2026, 3, 29, 2, 30, 0, 0, zone);

Если 02:30 не существует, библиотека автоматически переводит время в ближайшее корректное значение (обычно 03:30 или 03:00 в зависимости от правил зоны).

Ключевой принцип:

js-joda никогда не хранит несуществующее локальное время в ZonedDateTime без нормализации


Перекрытие времени (Overlap) и неоднозначные моменты

Перекрытие возникает при возврате на зимнее время, когда один и тот же локальный час существует дважды.

Пример:

  • 02:30 (UTC+2)
  • 02:30 (UTC+1)

Обе метки времени валидны, но соответствуют разным моментам UTC.

js-joda решает неоднозначность через выбор смещения:

const zdt = ZonedDateTime.of(2026, 10, 25, 2, 30, 0, 0, ZoneId.of('Europe/Berlin'));

По умолчанию выбирается первое подходящее смещение (ранний offset), если не задано иное поведение через API работы с переходами.

Для устранения неоднозначности используется преобразование в Instant:

const instant = zdt.toInstant();

Instant всегда однозначен и не зависит от локальных правил зоны.


Преобразование между LocalDateTime и ZonedDateTime

Критическая ошибка при работе с DST — хранение времени в LocalDateTime без зоны. Такое значение теряет контекст переходов.

const ldt = LocalDateTime.of(2026, 3, 29, 2, 30);
const zdt = ldt.atZone(ZoneId.of('Europe/Berlin'));

При вызове atZone происходит анализ:

  • существует ли локальное время
  • является ли оно частью разрыва
  • требуется ли сдвиг

В случае разрыва результат будет автоматически нормализован.


Явное управление переходами через Instant

Самый надёжный способ работы с временем в условиях DST — опора на UTC-момент:

const instant = Instant.now();
const zdt = instant.atZone(ZoneId.of('Europe/Berlin'));

Преимущества:

  • отсутствие неоднозначности
  • стабильность при исторических изменениях зон
  • независимость от локальных правил отображения

При обратном преобразовании:

const back = zdt.toInstant();

получается тот же исходный момент, независимо от DST.


Работа с длительностями через границы DST

Одна из наиболее сложных проблем — арифметика времени через переходы.

const zone = ZoneId.of('Europe/Berlin');

const start = ZonedDateTime.of(2026, 3, 28, 23, 30, 0, 0, zone);
const end = start.plusHours(3);

В момент перехода длина «часа» может быть не равна 60 минутам.

js-joda корректно учитывает:

  • фактическое смещение UTC
  • изменение продолжительности суток
  • пересчёт через правила зоны

Поэтому plusHours не просто добавляет 3600 секунд, а пересчитывает через Instant.


Сравнение ZonedDateTime и OffsetDateTime в контексте DST

OffsetDateTime хранит фиксированное смещение:

OffsetDateTime.of(2026, 3, 29, 2, 30, 0, 0, ZoneOffset.of('+01:00'));

Недостаток:

  • не учитывает исторические изменения зоны
  • не реагирует на DST правила

ZonedDateTime:

  • использует ZoneId
  • применяет правила переходов
  • корректно обрабатывает локальные аномалии

В системах с долгоживущими событиями (расписания, календарные системы) предпочтителен именно ZonedDateTime.


Исторические изменения зон и их влияние

Временные зоны не статичны. Они изменяются:

  • перенос начала/окончания DST
  • отмена летнего времени
  • изменение базового offset

js-joda использует tz-данные (аналог IANA Time Zone Database), благодаря чему:

ZoneId.of('Europe/Moscow')

может давать разные правила в зависимости от года события.

Пример:

  • в 2010 году DST мог существовать
  • в 2020 — отменён

При создании ZonedDateTime учитывается не только дата, но и исторический контекст зоны.


Нормализация времени при создании объектов

Каждое создание ZonedDateTime проходит через процесс нормализации:

  1. Проверка существования локального времени
  2. Определение списка возможных offset
  3. Выбор корректного смещения
  4. Привязка к Instant

Этот процесс гарантирует, что объект всегда представляет реальный момент времени.


Практическая модель безопасной работы с DST

Устойчивый подход в js-joda строится на разделении уровней:

  • хранение: Instant
  • отображение: ZonedDateTime
  • ввод пользователя: LocalDateTime
  • контекст зоны: ZoneId

Типичный поток:

const zone = ZoneId.of('Europe/Berlin');

const userInput = LocalDateTime.of(2026, 10, 25, 2, 30);
const zdt = userInput.atZone(zone);
const instant = zdt.toInstant();

Такой подход устраняет:

  • неоднозначные локальные времена
  • ошибки при DST-переходах
  • рассинхронизацию данных между системами

Особенности форматирования и отображения после DST

При выводе времени важно учитывать, что одно и то же значение Instant может отображаться по-разному в зависимости от зоны:

const instant = Instant.parse('2026-10-25T00:30:00Z');

const berlin = instant.atZone(ZoneId.of('Europe/Berlin'));
const london = instant.atZone(ZoneId.of('Europe/London'));

Разные зоны дают разные локальные времена, но одинаковый момент времени.


Ошибки проектирования при игнорировании переходов

Наиболее распространённые проблемы:

  • хранение дат в Date без учёта зоны
  • арифметика через миллисекунды вместо plusHours
  • использование LocalDateTime как абсолютного времени
  • игнорирование исторических изменений зон

js-joda устраняет эти ошибки за счёт строгой типизации временных сущностей и обязательного учёта ZoneId.


Роль ZoneRules и внутренней модели переходов

Каждая зона содержит набор правил:

  • список смещений
  • даты переходов
  • исторические корректировки

Эти правила используются при каждом вычислении ZonedDateTime, обеспечивая корректную работу даже для дат десятилетней давности или будущих прогнозируемых значений.