Назад в Центр знаний
Отпечатки

Строки даты: граница между местным временем и UTC

Один зонд по строкам дат: дата без времени читается как полночь UTC, строка дата-время без смещения читается как местное время, а невозможный день переносится.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser позволяет воспроизвести авторизованный фикстур того же источника под объявленным профилем и сохранить ограниченную запись точной отметки времени, которую страница произвела, но он не может заставить строку только с датой и строку с датой и временем назвать один и тот же момент, не может помешать переносу невозможного дня и не может заставить сохранённую строку сортироваться так, как сортируется её момент. Разбор запускает один зонд по десяти фиксированным случаям на хосте с America/Toronto и фиксирует, какие формы Date.parse читает как UTC, а какие как местное время, как четыре формы, произносящие один день, дают три разных момента, что делает невозможный день и провал перехода на летнее время, почему сортировка по строке и сортировка по моменту расходятся, и на какой местный час попадает каждая форма.

Кратко

  • Строка только с датой читается как полночь UTC, поэтому зонд печатает "2026-10-31" -> 2026-10-31T00:00:00.000Z и utc-equal -> Date.UTC=1793404800000 parse=1793404800000.
  • Строка дата-время без смещения читается как местное время, поэтому "2026-10-31T00:00:00" становится 2026-10-31T04:00:00.000Z на хосте в UTC-4.
  • Четыре формы, произносящие один день, дают distinct -> 3 instants from 4 shapes, потому что форма без времени это UTC, а формы со смещением сдвигают момент.
  • Невозможный день переносится: зонд просит 30 февраля и получает 2 марта, а "2026-02-30" разбирается в 2 марта, а не в ошибку.
  • Местное настенное время внутри провала перехода на летнее время сдвигается вперёд, поэтому 02:30 становится 03:30, а в повторе сохраняется одно из двух вхождений 01:30.
  • Сортировка по строке и сортировка по моменту расходятся: зонд печатает sort-by-ms -> 2026-10-31 , 2026-10-30T23:00:00 , ... против sort-by-string -> 2026-10-30T23:00:00 , 2026-10-31 , .... Второе сравнение, которое показывает зонд: две строки могут называть один момент и всё равно сортироваться иначе, то же расхождение, которое сортировка массива и компараторы разбирает для компаратора, не являющегося согласованным полным порядком. Форма Z читается одинаково в любой зоне, форма с временем сдвигается со смещением хоста, а форма без времени стабильна, но попадает на другой местный час.

Содержание

Первая панель показывает строку только с датой, прочитанную как полночь UTC, и строку дата-время без смещения, прочитанную как местное время, что даёт 04:00Z на хосте в UTC-4. Вторая показывает четыре формы, дающие три момента. Третья показывает 30 февраля, перенесённое на 2 марта, и 02:30 летнего времени, сдвинутое на 03:30. Четвёртая показывает сортировку по строке и по моменту, расходящиеся на одном списке.

Какие формы читаются как UTC, а какие как местные

Одни и те же поля календаря могут называть несколько моментов, и какая форма выбирает какой, зависит от формы, а не от даты. Зонд разбирает четыре строки и печатает момент ISO и числовое время:

"2026-10-31" -> 2026-10-31T00:00:00.000Z ms=1793404800000
"2026-10-31T00:00:00" -> 2026-10-31T04:00:00.000Z ms=1793419200000
"2026-10-31T00:00:00Z" -> 2026-10-31T00:00:00.000Z ms=1793404800000
"2026-10-31 00:00:00" -> 2026-10-31T04:00:00.000Z ms=1793419200000
distinct -> 3 instants from 4 shapes
shape:2026-10-31 -> 2026-10-31T00:00:00.000Z
shape:2026-10-31T00:00:00 -> 2026-10-31T04:00:00.000Z
shape:2026-10-31T00:00:00-04:00 -> 2026-10-31T04:00:00.000Z
shape:2026-10-31T00:00:00+09:00 -> 2026-10-30T15:00:00.000Z
utc-equal -> Date.UTC=1793404800000 parse=1793404800000

Первая строка задаёт правило для голой даты. "2026-10-31" разбирается в 2026-10-31T00:00:00.000Z, и последняя строка это подтверждает: utc-equal показывает, что разобранное значение равно ровно Date.UTC(2026, 9, 31). У строки только с датой поэтому нет местного чтения; это полночь UTC по определению, и страница, которая форматирует её обратно местным форматтером на хосте в UTC-4, покажет 30 октября, 20:00, то есть предыдущий день для читателя, ждавшего 31-е.

Вторая строка задаёт правило для строки дата-время без смещения. "2026-10-31T00:00:00" разбирается в 04:00Z на этом хосте, потому что отсутствующее смещение заполняется зоной хоста, а это UTC-4 на ту дату. Значение поэтому не тот же момент, что строка только с датой, и страница, смешивающая две формы в одном наборе данных, имеет две разные точки начала для того, что выглядит одним днём.

Третья строка это однозначная форма. "2026-10-31T00:00:00Z" разбирается в 00:00Z на любом хосте, потому что конечная Z явно называет UTC. Явное числовое смещение делает то же: форма -04:00 в строках shape: печатает 04:00Z, а форма +09:00 печатает предыдущий день в 15:00Z, обе один и тот же момент независимо от хоста.

Четвёртая строка рискованная. "2026-10-31 00:00:00" использует пробел вместо T, и зонд показывает, что она разбирается в 04:00Z, то же местное чтение, что у формы с T. Пробел поэтому работает так же на этом хосте, но форма с пробелом определяется реализацией, а не требуется, так что среда вправе прочитать её иначе или отвергнуть. Единственные формы, которые приложение должно хранить, это форма только с датой, то есть UTC, и форма дата-время с Z или явным смещением. То, как сохранённый момент становится видимой датой, это отдельный шаг, и формат даты и времени и переносимость часовых поясов фиксирует, что форматтер делает с зоной, которую получил.

Почему четыре написания одного дня это три момента

Строка distinct -> 3 instants from 4 shapes это сводка задачи. Четыре строки, которые читатель назвал бы «31 октября», дают три разных момента, потому что две из четырёх называют один и тот же момент двумя путями.

Форма только с датой это 00:00Z. Форма без смещения это 04:00Z на этом хосте, на четыре часа позже. Форма -04:00 тоже 04:00Z, поэтому она совпадает с формой без смещения из-за совпадения зоны этого хоста, а не по правилу. Форма +09:00 это 15:00Z предыдущего дня, на пять часов раньше пары 04:00Z. Значит четыре строки покрывают от предыдущего дня до запрошенного, и страница, которая считает их взаимозаменяемыми, две упорядочит неверно и две поместит на предыдущий день.

Совпадение между формой без смещения и формой -04:00 это ловушка. На хосте в UTC-4 они печатают один момент, поэтому тест, который идёт только там, проходит, а тот же код на хосте в UTC+9 печатает два разных момента для двух форм. Тест, который хочет доказать независимость кода от зоны, должен идти как минимум в двух зонах или проверять формы Z и со смещением, которые от хоста не зависят вовсе.

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

Строка offset-now -> 240 делает тот же вывод с другой стороны. Дата 2026-10-31 на этом хосте в летнем времени, поэтому её смещение -4. Хост, который форматировал бы её с фиксированным зимним смещением -05:00, был бы на час мимо для этой даты и верен зимой, а это именно тот дефект, который фиксированное смещение в фикстуре прячет, пока дата не пересечёт переход.

Две оставшиеся формы это те, что обычно производит фильтр панели. "2026" разбирается в 2026-01-01T00:00:00.000Z, а "2026-10" в 2026-10-01T00:00:00.000Z, обе как UTC, потому что частичная ISO дата не имеет ни времени, ни смещения. Фильтр, который шлёт месяц как 2026-10, поэтому называет первый момент этого месяца в UTC, а это предыдущий месяц для читателя к западу от Гринвича: здесь местное чтение 2026-09-30T20:00, метка месяца, показывающая неверный месяц. Панель, которой нужен «октябрь» для пользователя в Торонто, должна слать явное начало вроде 2026-10-01T00:00:00-04:00 вместо голого месяца или слать месяц текстом и форматировать его, не превращая в момент.

Формы "10/31/2026" и "Oct 31 2026" ведут себя наоборот и показывают, почему строка, отформатированная локалью, никогда не должна быть форматом хранения. Обе разбираются в 2026-10-31T04:00:00.000Z здесь, местное чтение, поэтому следуют зоне хоста, как форма без смещения. Но их приём определён реализацией, поэтому среда, следующая другому порядку даты, вправе прочитать 10/31/2026 как десятое тридцать первого месяца или отвергнуть. Страница, которая хранит дату в формате, который печатает её локаль, и потом разбирает на сервере в другой локали, имеет форму, принятую в одном месте и прочитанную иначе в другом, и дефект появляется как неверный месяц, а не как ошибка.

Строка оборота отвечает, переживает ли местное настенное время хранение. Зонд разбирает 2026-10-31T09:30:00, печатает момент 2026-10-31T13:30:00.000Z, перечитывает localHours=9 и подтверждает, что сохранённое число возвращает тот же момент. Это верный оборот настенных часов на одном хосте, и это ровно та форма, что ломается на другом: та же строка, прочитанная в зоне UTC+1, попадает в 08:30Z, поэтому сохранённый момент сдвигается на пять часов, а показанное настенное время остаётся 09:30 только потому, что показ использует ту же зону, что и разбор. Страница, которая хранит настенное время и форматирует обратно в той же зоне, не видит ошибки, а ошибка появляется только когда два конца работают в разных зонах.

Что переносится, а что отказывает

Не каждая недопустимая дата отказывает, и зонд отделяет формы, которые переносятся, от тех, что выбрасывают:

rollover -> 2026-03-02T05:00:00.000Z (Feb 30 requested)
rollover-leap -> 2026-03-01T05:00:00.000Z (Feb 29 in a common year)
parse-invalid -> "2026-02-30" -> 2026-03-02T00:00:00.000Z

Конструктор Date переносит день вне диапазона на следующий месяц. new Date(2026, 1, 30) просит 30 февраля, которого нет в обычном году, и результат это 2 марта, потому что конструктор добавляет лишние дни к последнему допустимому дню февраля. То же для 29 февраля в невисокосный год: new Date(2026, 1, 29) становится 1 марта. Страница, которая строит дату из трёх разобранных чисел, поэтому получает тихий перенос, и getMonth() результата не тот месяц, что передан.

Разбор строковой формы делает то же. new Date('2026-02-30') возвращает 2026-03-02T00:00:00.000Z вместо Invalid, потому что строка читается как дата без времени и день переносится. Это противоположно тому, что предполагают многие валидаторы, и значит, что проверка if (isNaN(Date.parse(value))) пропускает строку, которой нет ни в одном календаре. Валидация, которая должна отвергнуть 30 февраля, должна сравнить разобранные поля с входными вместо доверия анализатору.

Случаи перехода на летнее время показывают ту же тихую правку. Строка провала просит 02:30 в день американского весеннего перехода, час, которого нет, и зонд печатает 2026-03-08T07:30:00.000Z с местным временем 03:30. Среда сдвинула несуществующее настенное время вперёд на провал, поэтому построенная дата на час позже запрошенной, и getHours() результата равно 3, а не 2. Строка повтора просит 01:30 в день осеннего возврата, час, который случается дважды, и зонд печатает 2026-11-01T05:30:00.000Z с местным чтением 01:30, одно из двух вхождений и не обязательно то, что имел в виду читатель.

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

Почему сортировка по строке и по моменту расходятся

Две сортировки одного списка это самое ясное следствие, потому что они расходятся по порядку тех же четырёх строк:

sort-by-ms -> 2026-10-31 , 2026-10-30T23:00:00 , 2026-10-31T00:00:00 , 2026-11-01
sort-by-string -> 2026-10-30T23:00:00 , 2026-10-31 , 2026-10-31T00:00:00 , 2026-11-01

Сортировка по моменту ставит 2026-10-31 первой, потому что это полночь UTC того дня, и 2026-10-30T23:00:00 второй, потому что это местное настенное время предыдущего дня, прочитанное как 03:00Z следующего. Сортировка по строке ставит 2026-10-30T23:00:00 первой, потому что символы 2026-10-30 сортируются раньше 2026-10-31. Две сортировки поэтому расходятся на первых двух, и расхождение целиком из форм, а не из значений.

Компаратор, не являющийся согласованным полным порядком, даёт неверный и тихий порядок, тот же отказ, который описывает сортировка массива и компараторы. Сортировка массива по умолчанию это сортировка по строке, потому что сравнивает кодовые единицы элементов. Список дат, отсортированный sort() по умолчанию, поэтому отсортирован по тексту, что совпадает с хронологическим порядком только когда каждый элемент одной формы. Смешайте значение без времени со значением с временем, и порядок текста и хронологический порядок разойдутся, и ничто не предупредит.

Зона, которую называет сериализованная строка, это ещё и зона, которую справочник по часовым поясам и локали должен разрешить, прежде чем подписать дату. Исправление в том, чтобы сортировать по разобранному числу и хранить одну форму. Строка sort-by-ms это исправление, и она упорядочивает те же четыре строки по моменту, который они называют. Страница, которая хранит каждую отметку как строку Z или число и сортирует по этому значению, получает одинаковый порядок от сортировки по строке и от числовой, потому что форма однородна; страница, смешивающая формы, получает два порядка и не может сказать, какой увидит читатель.

Строки local-hour показывают ту же задачу со стороны показа. local-hour:2026-10-31 -> 20 читает полночь UTC значения без времени в зоне хоста и сообщает 20:00 предыдущего дня, тогда как local-hour:2026-10-31T00:00:00 -> 0 читает местную форму и сообщает 00:00, а local-hour:2026-10-31T00:00:00Z -> 20 читает форму Z и сообщает тот же 20:00, что значение без времени. Значит две из трёх форм показываются как предыдущий вечер, и только местная форма показывается как запрошенная полночь. Запись, которая хранит показанный час без формы, не может объяснить, почему две страницы, говорящие «31 октября», показывают разные часы.

Строки compare и diff-hours замыкают круг. Зонд печатает compare -> '2026-10-31' == new Date('2026-10-31') is true, что говорит лишь о том, что строка равна себе, затем diff-hours -> dateOnlyUtcHour=0 dateTimeUtcHour=4, что показывает, что две формы попадают на четыре часа врозь в UTC на этом хосте. Сравнение между значением без времени и значением с временем поэтому не сравнение одного дня; это момент в 00:00Z против момента в 04:00Z на этом хосте, которые различаются, хотя обе строки начинаются одной датой.

Запись одного показания разбора дат

Показание разбора дат подтверждает узкое утверждение: эта среда, эта зона, эти формы дали эти моменты. Четыре поля держат его проверяемым:

  1. Форма, потому что значение без времени это UTC, а строка дата-время без смещения это местное время, и форма не видна в моменте.
  2. Зона хоста, потому что форма без смещения от неё зависит, а формы Z и со смещением нет, и показание без зоны не воспроизводится.
  3. Момент в UTC, потому что настенное время, которое видит читатель, это второй шаг, который может отличаться по зоне.
  4. Ключ сортировки и сохранённая форма, потому что сортировка по строке и по моменту смешанного списка расходятся, и запись должна сказать, какая произвела порядок.

Публичные источники

Практический вывод

Храните одну форму и храните её как момент: строку Z или число, никогда местное настенное время, потому что значение без времени и строка дата-время без смещения называют разные моменты, а вторая зависит от зоны читателя. Не полагайтесь на Date.parse в отвержении невозможного дня, потому что 30 февраля переносится на 2 марта вместо Invalid; сравнивайте разобранные поля со входом. Сортируйте по разобранному числу, а не по сравнению строк по умолчанию, когда список может нести несколько форм, и помните, что настенное время внутри провала перехода на летнее время сдвигается вперёд, а внутри повтора это одно из двух вхождений. О половине показа той же задачи читайте формат даты и времени и переносимость часовых поясов; о данных локали, которые называют зону, читайте часовые пояса и язык локали.

#Date#Часовой Пояс#Разбор#ECMAScript#Переход На Летнее Время

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

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