Завтра в это же время: на удивление сложная реализация на JavaScript
Задача: имея юникстайм и название часового пояса, получить юникстайм этого же времени дня на следующий день.
Например: 1648292400 Europe/Prague (26 марта 2022, 12:00 по местному времени). Хотим получить 1648375200 Europe/Prague (27 марта 2022, 12:00 по местному времени).
Решение на JavaScript: на удивление сложное.
Первая наивная попытка
Добавим 24 часа к исходному таймстэмпу:
const timestamp = 1648292400; const timeZone = "Europe/Prague"; const thisTimeTomorrow = timestamp + 24 * 60 * 60; console.log(thisTimeTomorrow, timeZone); // 1648378800 Europe/Prague
На удивление, полученный тайстэмп соотвествует не 12:00, а 13:00 по местному времени. По совпадению, именно в ночь с 26 на 27 марта в Чехии переводят часы на летнее время — на час вперёд. Как результат, полдень 26 и 27 марта отделяет всего 23, а не 24 часа.
Вторая наивная попытка
Не будем работать с датой и временем самостоятельно, доверимся классу Date
стандартной библиотеки:
const timestamp = 1648292400; const date = new Date(timestamp * 1000); date.setDate(date.getDate() + 1); console.log(date); console.log(Math.floor(date / 1000));
Этот код прекрасно работает, если системный часовой пояс — Europe/Prague:
Sun Mar 27 2022 12:00:00 GMT+0200 (Central European Summer Time) 1648375200
(В этом легко убедиться, исполнив код в консоли Chrome. Предварительно в панели Sensors надо переопределить текущее местоположение и указать соответствующий Timezone ID.)
Если же код работает на серверах, которые принято держать в UTC, получим другой результат:
Sun Mar 27 2022 11:00:00 GMT+0000 (Coordinated Universal Time) 1648378800
Этот ошибочный результат ожидаемо совпадает с первой наивной попыткой.
Проблема с Date
Класс Date
умеет работать только с двумя часовыми поясами: системным и UTC. Методы getDate
, getHours
, toString
и другие интерпретируют таймстэмп по местному времени, а их аналоги getUTCDate
, getUTCHours
, toUTCString
— по UTC.
(Стилистические вопросы написания аббревиатур в идентификаторах выходят выходили бы за рамки этой статьи, если бы ответ не был прост: всегда getUtcDate
. Впрочем, после XMLHttpRequest
наши ожидания не высоки.)
В этом огромная слабость API для работы с датой и временем в JavaScript. Попытка закрыть этот недочёт предпринята в новом API Intl.DateTimeFormat
, который позволяет интерпретировать таймстэмп в любом часовом поясе:
const formatter = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Prague", dateStyle: "long", timeStyle: "long", }); console.log(formatter.format(new Date(1648292400 * 1000))); // March 26, 2022 at 12:00:00 PM GMT+1 console.log(formatter.format(new Date(1648375200 * 1000))); // March 27, 2022 at 12:00:00 PM GMT+2
Однако, говоря «интерпретировать таймстэмп», мы подразумеваем две задачи:
- Отформатировать числовой таймстэмп как строку в заданном часовом поясе
- Прочитать строку с датой и временем как таймстэмп в заданном часовом поясе
Intl.DateTimeFormat
решает первую задачу. Для второй задачи в JavaScript по-прежнему не существует простого решения.
Третья попытка
Несмотря на свою неполноту, Intl.DateTimeFormat
приближает нас к решению задачи, поскольку даёт доступ к ключевой информации: какой временной сдвиг действует в данный момент в данном часовом поясе.
const offsetPattern = /GMT([+-]\d+)/; function getTimeZoneOffset(timestamp, timeZone) { const formatter = new Intl.DateTimeFormat("en-US", { timeStyle: "long", timeZone, }); const localTimeStr = formatter.format(new Date(timestamp * 1000)); const offsetMatch = localTimeStr.match(offsetPattern); const offsetSecs = Number.parseInt(offsetMatch[1]) * 60 * 60; return offsetSecs; } console.log(getTimeZoneOffset(1648292400, "Europe/Prague")); // 3600 console.log(getTimeZoneOffset(1648375200, "Europe/Prague")); // 7200
Используя эту вспомогательную функцию, обновим наше первое наивное решение:
const timestamp = 1648292400; const timeZone = "Europe/Prague"; const tomorrow = timestamp + 24 * 60 * 60; const thisTimeTomorrow = tomorrow + getTimeZoneOffset(timestamp, timeZone) - getTimeZoneOffset(tomorrow, timeZone); console.log(thisTimeTomorrow, timeZone); // 1648375200 Europe/Prague
Заключительные примечания
Во-первых, наше решение ломается на 1648258200 Europe/Prague (26 марта 2022, 2:30 по местному времени) — мы получим 1648341000 Europe/Prague (27 марта, 01:30 по местному времени). Впрочем, не понятно, что значит «завтра в это же время» в таком случае: стрелки переводятся вперёд в два часа ночи, так что 02:30 просто не существует 27 марта.
Во-вторых, естественно, эта задача уже решена в библиотеках. Например, с использованием date-fns-tz
:
const thisTimeTomorrow = zonedTimeToUtc( addDays(utcToZonedTime(timestamp, timeZone), 1), timeZone );
В-третьих, эта задача хорошо показывает, почему недостаточно хранить только временной сдвиг часового пояса вместе с тайстэмпом. Всегда храните название часового пояса по IANA.
В-четвёртых, страны иногда меняют правила перехода на летнее время и временные сдвиги часовых поясов. Не факт, что ваш рантайм своевременно отразит эти изменения. Например, Node.js бандлит библиотеку ICU, содержащую эту информацию. Не обновив Node.js, вы не получите актуальную информацию о часовых поясах.
Если эта информация критична для работы вашего приложения, пересоберите Node.js с флагом --with-intl=system-icu
и своевременно обновляйте библиотеку ICU в вашей системе.
Вывод
Не завидую тем, кому приходится работать с часовыми поясами.