May 22, 2022

Завтра в это же время: на удивление сложная реализация на 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

Однако, говоря «интерпретировать таймстэмп», мы подразумеваем две задачи:

  1. Отформатировать числовой таймстэмп как строку в заданном часовом поясе
  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 в вашей системе.

Вывод

Не завидую тем, кому приходится работать с часовыми поясами.