javascript

Как не ошибиться при работе с датами и временем. Разбираем все нюансы и ограничения

  • суббота, 22 августа 2026 г. в 00:00:05
https://habr.com/ru/companies/ispring/articles/1053384/

Всем привет! Меня зовут Денис Смирнов, больше шести лет я работаю в компании iSpring и участвую в разработке нашего основного продукта — платформы для онлайн-обучения.

Эта статья - текстовая версия моего доклада на нашем митапе, полное видео можно посмотреть здесь.

Особенность платформы в том, что в ней достаточно гибко настраивается работа с датой и временем. Часовые зоны (таймзоны) можно указать на уровне аккаунта, тогда каждый пользователь может выбрать собственный часовой пояс. В некоторых модулях их можно дополнительно переопределять для отдельных пользователей. Например, в модуле «Планы развития» одно и то же назначение может отображаться для людей из разных часовых поясов.

Модуль "Планы развития"
Модуль "Планы развития"

За время разработки и поддержки этого функционала мы столкнулись с большим количеством проблем. В этой статье хочу рассказать о том, что стоит учитывать, если вам тоже придётся работать с датой и временем в распределённой системе.

Сразу оговорюсь: во всех примерах в статье для работы с датой и временем будет использоваться библиотека Luxon, если не указано иное.

Часовые зоны — это не просто смещение от UTC

Когда говорят о таймзонах, обычно вспоминают всемирное координированное время — стандарт UTC. Например, говорят, что Москва находится в UTC+3, а Нью-Йорк — в UTC−5. На первый взгляд кажется, что этого достаточно для работы с датой и временем. Но в реальности всё устроено сложнее.

Чтобы разобраться, нужно вспомнить, что такое UTC и как появился этот стандарт. До появления единой системы отсчёта люди ориентировались по солнечному времени, из-за чего возникало множество неудобств: например, полдень в одном городе мог наступать на 20–30 минут раньше или позже, чем в соседнем. Особенно остро эта проблема проявилась в XIX веке, когда начали активно развиваться железные дороги и морские перевозки — для синхронизации расписаний понадобилось единое время.

В 1884 году на Международной меридианной конференции Гринвичский меридиан был принят за нулевой, а также появилась система часовых поясов GMT. Однако со временем этого стандарта стало недостаточно. В XX веке с появлением атомных часов понадобился более точный способ измерения времени.

В 1960-х годах начали разрабатывать новый стандарт, основанный на атомном времени. В 1967 году Международный астрономический союз и Международное бюро времени перешли на координированное всемирное время — UTC (Coordinated Universal Time). С тех пор UTC используется как основной мировой эталон времени, но сокращение GMT по-прежнему широко распространено и фактически является синонимом UTC.

Кроме того, раз в несколько лет к мировому времени добавляют одну секунду, чтобы скорректировать вращение Земли. Формально такая возможность в стандарте предусмотрена, но в основном её не используют. Например, в JS объект Date работает без учёта этих дополнительных секунд. К тому же, с 2035 года традиция добавления «високосных секунд» к Всемирному координированному времени (UTC) будет прекращена. Поэтому про них можно забыть.

В программных системах время UTC чаще всего кодируют в UNIX timestamp, который также называют Epoch. По сути это другое представление зоны UTC+0, то есть непрерывное и однозначное время. Если UTC состоит из года, месяца, дня, часов и так далее, то UNIX timestamp — это просто счётчик секунд с 1 января 1970 года, удобный формат хранения и передачи информации, который позволяет экономить байты и упрощает сравнение дат между собой.

Далее в статье я буду называть время в UTC+0 или Epoch абсолютным временем, а время в конкретном часовом поясе — локальным или местным. Если посмотреть на карту мировых часовых зон, можно заметить, что с одной стороны, есть условное, «идеальное» деление Земли на часовые пояса, основанное на географии. Земной шар разбивается на равные полосы, каждая из которых соответствует одному часу относительно Гринвича. В такой модели границы часовых зон (таймзон) проходили бы строго по меридианам и выглядели бы достаточно ровно и предсказуемо.

Карта мировых часовых зон. Автор: Goran tek-en, источник.
Карта мировых часовых зон. Автор: Goran tek-en, источник.

Однако в реальности эта схема почти не используется. Мы живём не в абстрактной географической модели, а в государствах, и именно они определяют, какое время действует на их территории. В результате границы часовых зон подстраиваются под политические, экономические и социальные факторы и часто сильно отклоняются от «идеальных» линий. Именно с ними нам и приходится иметь дело в разработке.

База данных часовых зон: как пользоваться

Возникает логичный вопрос: где вообще взять актуальный список таймзон и понять, как они устроены. Практически все современные системы используют базу данных, которую ведёт IANA. Это некоммерческая организация, известная в том числе тем, что управляет доменами верхнего уровня и корневыми сертификатами, а параллельно поддерживает и базу часовых зон.

Эта база доступна публично и представляет собой набор текстовых файлов, в которых специальным образом описаны правила для каждой часовой зоны. Поскольку государства не обязаны централизованно предоставлять такие данные, информация собирается из открытых источников: нормативных актов,публикаций в СМИ и других материалов. Если открыть эти файлы, можно увидеть большое количество ссылок на первоисточники, на основе которых формируются правила.

Помимо самих данных, в состав базы входят вспомогательные инструменты, в том числе код на языке C, который используется для работы с этими правилами. В итоге именно эта база лежит в основе большинства современных систем: она встраивается в ОС, браузеры и библиотеки для работы с датой и временем. Фактически это стандарт, на который опирается практически вся индустрия.

В таблице - упрощённое представление этой базы, с помощью которого будет легче понять, как именно устроены часовые зоны на практике.

В первую очередь нас интересует идентификатор таймзоны — это второй столбец. Он имеет стандартизированный формат и состоит из двух частей:

  • сначала указывается регион (как правило континент или океан),

  • затем — конкретная географическая точка, чаще всего город.

Например, Europe/Moscow или America/New_York. Такие названия называются каноническими идентификаторами и используются как основной стандарт.

При этом в базе можно встретить и неканонические имена — устаревшие или альтернативные варианты, которые сохраняются для обратной совместимости. Например, US/Eastern по сути указывает на ту же часовую зону, что и America/New_York.

Далее в таблице обычно указывается смещение относительно UTC. Здесь появляется первая важная особенность: у одной часовой зоны может быть не одно, а несколько значений смещения. Это связано с практикой перехода на летнее и зимнее время, когда часы переводятся, как правило, на один час вперёд или назад. В результате в течение года смещение может меняться.

SettingsSettings.defaultZone = 'Europe/Moscow'
const format = DateTime.DATETIME_FULL

DateTime
  .fromISO('1992-02-01')
  .toLocaleString(format)
// 1 февраля 1992 г. в 00:00 GMT+3

DateTime
  .fromISO('1992-06-01')
  .toLocaleString(format)
// 1 июня 1992 г. в 00:00 GMT+4

Таким образом, уже на этом уровне видно, что часовая зона — это не фиксированное число, а изменяемая величина. Но и это ещё не вся сложность.

Вывод №1 Часовая зона (таймзона) — это не просто смещение от UTC, это смещение в конкретный момент времени. Это означает, что при работе с датой необходимо учитывать исторические изменения и переходы на летнее и зимнее время, которые могут влиять на итоговое значение. Надо использовать не фиксированные смещения, а идентификаторы. Тогда библиотека, с которой вы работаете, сможет корректно учитывать все изменения.

Почему один день — не всегда 24 часа

Следующая распространённая ошибка при работе с датой и временем в программировании связана с тем, что с детства мы привыкли считать: в сутках всегда 24 часа.

Представим, что нам нужно получить тот же момент времени на следующий день. Часто это делают так: переводят текущее время в Epoch и просто прибавляют количество секунд или миллисекунд, соответствующее 24 часам. Но в этом подходе есть проблема. Смещение относительно UTC в пределах одной часовой зоны может меняться. А если оно меняется, значит, существует конкретный момент такого изменения.

В результате сутки в локальном времени могут быть не ровно 24 часа, и простое прибавление фиксированного количества секунд может дать неверный результат.

Settings.defaultZone = 'America/New_York'

const dateStart = DateTime.fromISO('2025-03-09')
const dateEnd = DateTime.fromISO('2025-03-10')

dateEnd.diff(dateStart).shiftTo('hours').toHuman() // 23 hours!
dateStart.plus({hours: 24}) // 2025-03-10T01:00
dateStart.plus({days: 1})   // 2025-03-10T00:00

Давайте разберёмся, что происходит в такие моменты. Возьмём часовую зону America/New_York, где до сих пор используется переход на летнее время. Например, 9 марта 2025 года часы были переведены на один час вперёд — и один час просто «исчез» из локального времени.

Что это означает на практике? Если мы создадим даты, соответствующие началу дня 9 марта и 10 марта, и посчитаем разницу между ними, то получим не 24, а 23 часа. Соответственно, если попытаться получить начало следующего дня, просто прибавив к 9 марта 23 часа, мы не попадём точно в начало 10 марта.

А вот если прибавлять именно дни, а не часы, то библиотека корректно учитывает переход на летнее время — и в результате мы действительно получаем начало дня 10 марта.

Неправильно

const currDay = DateTime.now()
const nextDay = DateTime.fromMillis(currDay.toMillis() + 24 * MILLISECONDS_PER_HOUR)

Правильно

const currDay = DateTime.now()
const nextDay = DateTime.plus({'day': 1})

Обратная ситуация возникает осенью, когда жители Нью-Йорка переходят обратно на зимнее время. В этот момент один час как бы «проживается» дважды. Если взять начало дня 1 ноября и 2 ноября и посчитать разницу между ними, она составит уже 25 часов.

Settings.defaultZone = 'America/New_York'

const dateStart = DateTime.fromISO('2025-11-02')
const dateEnd = DateTime.fromISO('2025-11-03')

dateEnd.diff(dateStart).shiftTo('hours').toHuman() // 25 hours!
dateStart.plus({hours: 24}) // 2025-11-02T23:00
dateStart.plus({days: 1})   // 2025-11-03T00:00

Из этого следует важный нюанс: если просто прибавить 24 часа, мы всё ещё останемся в пределах того же календарного дня. Чтобы получить начало следующего дня, нужно прибавлять именно один календарный день, а не фиксированное количество часов.

Например, если вам нужно отправить письмо ровно через 48 часов от текущего момента и неважно, на какое локальное время это попадёт, - тогда действительно имеет смысл оперировать часами и просто прибавить 48 часов.

Но если задача - отправить письмо послезавтра в 12:00, то подход должен быть другим: сначала прибавить два календарных дня, а затем установить нужное время. Если же пытаться делать это через секунды или миллисекунды, результат может «съехать» из-за переходов между летним и зимним временем.

Вывод №2 Общее правило: в коде нельзя считать, что один день всегда равен 24 часам - эти понятия нужно чётко различать.

Часовые зоны и валидность данных

В интерфейсах почти всегда есть момент, когда пользователю надо ввести свои дату и время. А значит, нам нужно не только принять эти данные, но и корректно их валидировать. Давайте посмотрим на наши примеры с точки зрения валидности данных

Settings.defaultZone = 'America/New_York'

const date = DateTime.fromISO('2025-03-09T02:30')
date.toISO() // 2025-03-09T03:30

Так, здесь при переходе на летнее время возникает «дыра» во времени. Пользователь может ввести, например, 2:30 - но такого времени в выбранной таймзоне просто не существует. Что произойдёт в этом случае? Универсального ответа нет: поведение зависит от языка, библиотеки и платформы.

Например, в данном случае библиотека Luxon ведёт себя так же, как и нативный тип данных Date: дата создаётся без ошибок, исключения не возникает. Но если посмотреть, что получилось на самом деле, окажется, что время автоматически сдвинулось на час вперёд. В одних приложениях это может быть допустимо, в других — критично. Например, в системах, связанных с логистикой, такие неявные и непредсказуемые изменения могут приводить к серьёзным проблемам.

При этом «потерянный» час - далеко не самый экстремальный случай. Бывают ситуации, когда из календаря исчезает целый день.

Например, государство Самоа, расположенное в Тихом океане рядом с линией смены дат, в 2011 году сменило часовую зону с UTC−10 на UTC+14, чтобы упростить торговлю с Австралией и Новой Зеландией. В результате произошёл скачок на сутки вперёд, и дата 30 декабря 2011 года в часовой зоне Pacific/Apia просто не существует.

Это хороший повод проверить свои компоненты календаря: что произойдёт, если пользователь введёт несуществующую дату?

При обратном переходе — на зимнее время — ситуация другая. Здесь время не исчезает, а, наоборот, дублируется. Один и тот же локальный час происходит дважды, но соответствует разным моментам времени в UTC.

Settings.defaultZone = 'America/New_York'
const date = DateTime.fromISO('2025-11-02T01:30')
date.toSeconds() // 1762061400

DateTime.fromSeconds(1762061400).toISO() // 2025-11-02T01:30:00-04:00
DateTime.fromSeconds(1762065000).toISO() // 2025-11-02T01:30:00-05:00

Если пользователь введёт время из этого интервала, система создаст дату и преобразует её в epoch. Но какое именно из двух возможных значений будет выбрано - заранее неочевидно. Это снова зависит от конкретной реализации: языка, библиотеки или платформы.

Поэтому такие кейсы обязательно нужно проверять в своём продукте, чтобы понимать. В работе с датами и временем сюрпризы — скорее правило, чем исключение.

Два способа конвертации времени: в чём разница

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

Самый распространённый и стандартный способ — это конвертация через абсолютное время. У нас есть некий момент времени в UTC (или в виде epoch), и мы просто отображаем его в нужной часовой зоне. При таком подходе абсолютное значение остаётся неизменным, а локальное время, как правило, меняется.

const date1 = DateTime.fromISO('2025-03-09T13:00', {zone: 'America/New_York'})
const date2 = date1.setZone('Europe/Moscow') // 2025-03-09T20:00
date1.toMillis() === date2.toMillis() // true

В библиотеке Luxon это делается довольно просто: достаточно указать другую часовую зону, и мы получаем новое локальное представление того же самого момента времени. Именно так обычно и поступают при отображении дат и времени в интерфейсе.

Однако иногда, хоть и довольно редко, возникает другая задача: нужно выполнить конвертацию с сохранением локального времени. То есть мы задаём, например, 13:00 в Нью-Йорке и хотим, чтобы при смене часовой зоны на Москву осталось тоже 13:00 (с той же датой), даже если это уже другой момент в абсолютном времени.

const date1 = DateTime.fromISO('2025-03-09T13:00', {zone: 'America/New_York'})
const date2 = date1.setZone('Europe/Moscow', {keepLocalTime: true}) // 2025-03-09T13:00
// Альтернативный вариант
// const date2 = DateTime.fromObject(date1.toObject(), {zone: 'Europe/Moscow'})
date1.toMillis() === date2.toMillis() // false

Как это будет реализовано — зависит от используемой библиотеки. В Luxon для этого есть специальный флаг keepLocalTime: достаточно его указать, и локальное время сохранится.

Если в вашей библиотеке такой возможности нет — это не проблема. Можно пересоздать дату вручную по частям: либо через формат ISO, либо разобрав её на компоненты (год, месяц, день, часы и так далее), а затем создать новую дату с теми же значениями, но уже в другой часовой зоне.

Важно помнить, что в этом случае изменится именно абсолютное время. То есть локально это будут те же 13:00, но соответствовать они будут уже другому моменту в UTC.

Как передавать дату и время в клиент-серверном взаимодействии

По сути мы разрабатываем распределённые системы. То есть база данных с информацией о часовых зонах и смещениях встраивается в программное обеспечение на сервере и стороне клиента. Соответственно, версии этой базы данных могут отличаться на той или другой стороне. Например, мы обновили сервер до последней версии, а пользователь не обновлял браузер целый год.

Чтобы уменьшить вероятность таких проблем, можно использовать один из трёх подходов.

  1. Самый лучший вариант — регулярно производить обновления библиотек, операционной системы и так далее. Но это не всегда возможно.

  2. Зафиксировать список часовых зон, которые знает сервер и просить клиента выбрать из этого ограниченного списка. Это защитит от переименований, введений новых или удалений старых часовых зон. Но при этом не спасёт от изменений текущего смещения в существующих часовых зонах.

  3. Передавать не только идентификатор, но зафиксировать смещение. Это не идеальный вариант, но возможно, он подойдёт, если не критично соблюдать точность времени.

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

Первый — передавать Epoch timestamp и отдельно идентификатор часовой зоны. Это наиболее надёжный вариант.

timestamp: {stamp: 1762061400, zone: 'Europe/Moscow'}

Второй — использовать формат ISO 8601, который популярен для передачи информации в базах данных. Здесь нужно учесть, что в оригинальном формате предусмотрено указание только фиксированного смещения, а это, как мы выяснили, не оптимальное решение. Но недавно появилось дополнительное расширение к формату, которое даёт возможность указывать коды идентификаторов часовой зоны в квадратных скобках.

ISO 8601:   '2025-09-20T10:00:00+03:00'
ISO 8601 + RFC 9557:   '2025-09-20T10:00:00+03:00[Europe/Moscow]'

Если вы используете формат ISO, проверьте, что делаете это именно так, и библиотеки на обеих сторонах правильно считывают данные, а не используют фиксированное смещение.

Часовые зоны в JavaScript

Во-первых, у нас есть стандартный тип данных Date. Все, наверное, знают историю его появления: когда создавался JavaScript, одной из задач было максимально повторить популярный на тот момент Java. В итоге позаимствовали не только синтаксис, но и часть стандартной библиотеки — в том числе работу с датами. При этом в Java позже признали, что с этим API есть проблемы, и сделали новую, более продуманную реализацию. А вот в JavaScript мы до сих пор живём с тем, что есть.

Главное ограничение Date в том, что он работает только с одной часовой зоной — системной, то есть той, которая задана у пользователя в настройках операционной системы или браузера. Соответственно, вы либо используете её, либо работаете через UTC и конвертируете туда-обратно.

Кроме этого, у Date есть и другие проблемы:

  • нет арифметики (прибавить дни просто не получится),

  • отсчёт месяцев с 0, а дней — с 1,

  • нет интервалов и типов,

  • мутабельность (изменяемость) - что по современным меркам считается плохой практикой.

Если вашему приложению не требуется полноценная поддержка разных часовых зон, с Date в целом можно жить. Но тогда имеет смысл использовать вспомогательные библиотеки, например date-fns. Это набор функций, который упрощает работу с датами: добавляет удобную арифметику, делает API более предсказуемым.

Проблемы с Date в JavaScript были известны давно, поэтому технический комитет языка довольно долго разрабатывал его замену. В итоге появился API Temporal. В нём предложена более строгая модель работы с датами: несколько разных типов данных под разные задачи. Но на практике не всегда удобно работать с таким количеством разных типов, особенно в прикладных задачах.

Если посмотреть на поддержку, то видно, что ситуация пока неоднозначная: полноценная реализация сейчас доступна, например, только в Firefox. В интернете легко можно найти полифилы (код, реализующий функциональность, которая не поддерживается в некоторых браузерах), но про их качество ничего сказать не могу. Поэтому использовать API Temporal можно, но на свой страх и риск.

У всех остальных на практике остаётся выбор из двух основных библиотек, которые пришли на смену устаревшей Moment.js:

1. Luxon — библиотека от авторов Moment.js.

16,2 млн. скачиваний, 82 Kb, монолитная архитектура

DateTime
   .fromISO('2025-09-20', {
       zone: 'Europe/Moscow',
   })
   .plus({'day': 2})
   .toLocaleString(DateTime.DATE_MED)

2. dayjs — более лёгкий аналог, который во многом вдохновлялся синтаксисом Moment.

28,5 млн. скачиваний, 7 Kb (без плагинов), модульная архитектура (плагины)

dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(LocalizedFormat)
dayjs
   .tz('2025-09-20', 'Europe/Moscow')
   .add(2, 'day')
   .format('LL')

Разница между ними не только в синтаксисе. Luxon — это единый полноценный пакет: вы сразу получаете работу с датами, временем, интервалами и длительностями. За это приходится платить размером — порядка 80 Кбайт, но всё уже включено.

У dayjs другой подход — это плагинная система. Базовый пакет очень лёгкий, около 7 Кбайт, но всю функциональность нужно подключать отдельно. В итоге, если собрать аналогичный набор возможностей, размер получается сопоставимым с Luxon.

Что выбирать — смотрите сами. Лично мы выбрали Luxon и в целом им довольны.

Что стоит запомнить при работе с часовыми зонами (таймзонами)

Работа с таймзонами почти всегда оказывается сложнее, чем кажется в начале проекта. Чтобы избежать ошибок, важно помнить несколько вещей:

  • часовая зона — это не фиксированное смещение;

  • внутри одной зоны смещение может меняться;

  • день не всегда равен 24 часам; локальное время может не существовать или существовать дважды;

  • клиент и сервер могут использовать разные версии базы таймзон;

  • нативный Date в JavaScript плохо подходит для сложной работы с временем.

И, пожалуй, главный вывод, к которому мы пришли за время разработки: если у вас есть возможность не поддерживать произвольные часовые зоны — лучше этой возможностью воспользоваться. Это сэкономит много времени и денег.