Даты и часовые пояса в JavaScript без боли
Шесть сниппетов для реальных проблем с часовыми поясами: определение зоны посетителя, форматирование в произвольной зоне IANA, работа с YYYY-MM-DD без сдвига на день и подсчёт секунд до смены суток по UTC.
Ошибки с датами почти никогда не являются ошибками в арифметике дат. Обычно это смешение трёх разных вещей, которые программа называет словом «дата»: момент времени, календарная дата без времени и показание настенных часов в какой-то зоне. Стоит смешать две из них в одной переменной — и получается сдвиг на сутки, который проявляется только у пользователей западнее Гринвича или только летом.
Классика: дата, уехавшая на день
new Date('2026-09-04') разбирается как полночь по UTC, а getFullYear(), getMonth() и getDate() читают её в локальной зоне машины. Для любого отрицательного смещения эта комбинация возвращает третье сентября. Та же ловушка работает и на выходе: toISOString() конвертирует обратно через UTC, поэтому дата, выбранная как «сегодня» в Сиднее, сериализуется вчерашним числом.
У календарной даты нет часового пояса. Лечение — не давать ей его приобрести: либо держать строкой, либо создавать и читать строго в одной и той же зоне.
Форматирование в чужой зоне
Intl.DateTimeFormat принимает опцию timeZone и делает всю работу, включая исторические правила: он знает, какие недели 2026 года конкретная зона проводит на летнем времени, потому что читает базу часовых поясов IANA, поставляемую с рантаймом. Это важно, потому что правила перехода — политика, а не физика: страны меняют их за несколько месяцев до вступления в силу, и у любого захардкоженного смещения есть срок годности.
Intl.DateTimeFormat().resolvedOptions().timeZone сразу отдаёт идентификатор зоны посетителя по IANA. Это гораздо лучший вариант по умолчанию, чем спрашивать, и гораздо лучше getTimezoneOffset(), который возвращает только текущее смещение и зимой не отличает Europe/Berlin от Africa/Lagos.
Смещение — вопрос отображения, а не хранения
Показать пользователю «UTC+3» рядом с названием зоны полезно. Хранить «UTC+3» — нет: теряется, из какой зоны оно получено, и дважды в год значение становится неверным. Храните идентификатор IANA, а смещение вычисляйте при каждом выводе.
Последний сниппет — секунды до смены суток или месяца по UTC — всплывает в логике инвалидации кэша и ежедневных сбросов, где сброс обязан произойти в один и тот же момент для всех пользователей независимо от их местоположения.
Один сниппет здесь на PHP, а не на JavaScript: на сервере задача та же самая, и две реализации полезно прочитать рядом.
Автоматическое определение часового пояса посетителя
Одна строка, чтобы получить IANA-часовой пояс посетителя (например, "Asia/Jerusalem") прямо из браузера, без запроса геолокации.
Как это работает: Intl.DateTimeFormat().resolvedOptions() читает часовой пояс, настроенный в ОС/браузере — работает везде, где есть современный JS, и никогда не вызывает запрос разрешения navigator.geolocation.
function getVisitorTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
// Example usage
getVisitorTimezone() // "Europe/Berlin", "Asia/Jerusalem", etc.
Форматирование даты/времени в любом IANA-часовом поясе
Отображает дату так, будто вы находитесь в конкретном часовом поясе, независимо от локального времени посетителя.
Как это работает: передача timeZone в Intl.DateTimeFormat заставляет его напрямую вычислить время в этом поясе — без ручной математики со смещениями, и автоматически учитывается переход на летнее/зимнее время.
function formatInTimezone(date, timeZone, options = {}) {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric', month: 'short', day: '2-digit',
hour: '2-digit', minute: '2-digit',
...options,
timeZone
}).format(date)
}
// Example usage — same instant, two different timezones
const now = new Date()
formatInTimezone(now, 'Asia/Tokyo') // "Jul 9, 14:32"
formatInTimezone(now, 'America/New_York') // "Jul 9, 01:32"
Date в/из "YYYY-MM-DD" без сдвига часового пояса
date.toISOString().slice(0,10) незаметно переводит дату в UTC и может показать вчерашний день ближе к полуночи — эти два хелпера остаются в локальном времени.
Как это работает: вместо toISOString() (который сначала переводит в UTC), оба хелпера читают/собирают дату напрямую из локальных getFullYear/getMonth/getDate, поэтому календарный день никогда не сдвигается из-за часового пояса посетителя.
function toIsoDateString(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function parseIsoDate(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!match) return null
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
}
// Example usage
toIsoDateString(new Date()) // "2026-07-08", using local time, not UTC
parseIsoDate('2026-07-08') // Date for July 8, 2026 at local midnight
Секунды до следующей смены суток/месяца по UTC
Вычисляет, сколько секунд осталось до полуночи UTC (или до 1-го числа следующего месяца) — именно такой TTL нужен для ключа Redis с дневной/месячной квотой.
Как это работает: строится точная UTC-метка времени для границы следующего дня/месяца через Date.UTC(...), затем из неё вычитается текущее время — никакой возни с часовыми поясами, поскольку всё остаётся в UTC.
function secondsUntilNextUtcDay(date = new Date()) {
const nextDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1))
return Math.floor((nextDay.getTime() - date.getTime()) / 1000)
}
function secondsUntilNextUtcMonth(date = new Date()) {
const nextMonth = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1))
return Math.floor((nextMonth.getTime() - date.getTime()) / 1000)
}
// Example usage — set a Redis quota key to expire exactly at UTC midnight
await redis.set(`quota:daily:${userId}`, count, 'EX', secondsUntilNextUtcDay())
Отображение часового пояса со смещением GMT
Превращает название IANA-часового пояса в удобную метку вроде "(GMT+02:00) Asia/Jerusalem" для селекторов часовых поясов.
Как это работает: Intl.DateTimeFormat с timeZoneName: 'shortOffset' возвращает часть вида "GMT+3"; затем смещение нормализуется к фиксированному формату +HH:MM, чтобы все метки были выровнены в выпадающем списке.
function formatTzWithGmt(timeZone, date = new Date()) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
timeZoneName: 'shortOffset'
}).formatToParts(date)
const raw = parts.find((p) => p.type === 'timeZoneName')?.value || 'GMT'
let offset = raw.replace('GMT', '') || '+00:00'
if (!offset.includes(':')) {
const sign = offset.startsWith('-') ? '-' : '+'
offset = `${sign}${offset.replace(/[+-]/, '').padStart(2, '0')}:00`
}
return `(GMT${offset}) ${timeZone}`
}
// Example usage
formatTzWithGmt('Asia/Jerusalem') // "(GMT+03:00) Asia/Jerusalem"
Получение текущего GMT-смещения часового пояса в PHP
Превращает название IANA-часового пояса в удобную метку вроде "(GMT+02:00) Asia/Jerusalem" с учётом перехода на летнее время.
Как это работает: DateTimeZone::getOffset() возвращает смещение в секундах для конкретного момента времени, поэтому передача сегодняшней даты автоматически учитывает, действует ли сейчас переход на летнее время.
<?php
function formatTzWithGmt(string $timezone, ?DateTimeImmutable $date = null): string
{
$date ??= new DateTimeImmutable('now');
$tz = new DateTimeZone($timezone);
$offsetSeconds = $tz->getOffset($date);
$sign = $offsetSeconds >= 0 ? '+' : '-';
$hours = str_pad((string) intdiv(abs($offsetSeconds), 3600), 2, '0', STR_PAD_LEFT);
$minutes = str_pad((string) (intdiv(abs($offsetSeconds), 60) % 60), 2, '0', STR_PAD_LEFT);
return "(GMT{$sign}{$hours}:{$minutes}) {$timezone}";
}
// Example usage
formatTzWithGmt('Asia/Jerusalem'); // "(GMT+03:00) Asia/Jerusalem"
formatTzWithGmt('America/New_York'); // "(GMT-04:00) America/New_York" during DST
Другие подборки сниппетов
Контактная информация
Оставьте заявку или напишите напрямую в телеграм. Я отвечу в течение 24 часов.