Время выглядит простым типом данных, пока не приходит отчёт, где события за сутки не сходятся, или пользователь не жалуется, что напоминание пришло на час раньше. Тогда выясняется, что «время» — это как минимум три разные вещи, и путать их дорого.
Три вещи под одним словом
Момент. Точка на мировой оси: когда именно произошло событие. Не зависит от того, где находится наблюдатель. Момент создания заказа один и тот же для всех.
Местное время. Показания часов в конкретном месте: «девять утра» без уточнения, где именно. Само по себе не определяет момент — девять утра в разных местах наступает в разное время.
Намерение на будущее. «Встреча в понедельник в десять» — это не момент и не просто местное время, а обещание, которое должно выполниться по часам участника, что бы ни случилось с правилами перевода часов.
Отсюда главное правило: хранить надо то, что означает событие, а не то, что удобно показать. Момент хранится как момент. Намерение хранится как местное время плюс зона. Смешение этих двух и есть источник почти всех ошибок.
Тупик: перевести всё в одну зону и забыть
Первое, что пробуют, — хранить всё в единой зоне и переводить при показе. Для моментов это правильно и достаточно.
Для будущих событий не помогает. Встреча, назначенная на десять утра и сохранённая как момент, при изменении правил перевода часов в стране участника сдвинется на час. Формально момент сохранён верно; фактически обещание нарушено — человек придёт не тогда, когда договаривались.
Правила перевода часов меняются чаще, чем принято думать, и решение об этом принимается за месяцы, а не за годы. Программа, сохранившая будущее намерение как момент, окажется неправа задним числом.
Второй частый ход — хранить местное время без зоны, потому что «у нас все в одном городе». Работает, пока это так. Ломается не постепенно, а сразу: в день, когда появляется первый пользователь в другом месте, и чинить приходится уже накопленные данные.
Где ломается чаще всего
Границы суток. «События за сегодня» — это интервал, зависящий от зоны. Отчёт, посчитанный по одной зоне, и отчёт по другой дадут разные числа на одних данных, и оба будут верны. Спор о том, какой правильный, решается не кодом, а договорённостью, которую надо записать.
Сутки не всегда длятся сутки. В дни перевода часов они короче или длиннее. Код, прибавляющий фиксированный интервал вместо календарного дня, в эти дни ошибается, а тесты, написанные на обычной неделе, этого не показывают.
Несуществующее и повторяющееся время. При переводе вперёд час пропадает: назначенного на него события не существует. При переводе назад час повторяется дважды, и «половина третьего» означает два разных момента. Обе ситуации возникают дважды в год и обе роняют код, который их не ждёт.
Разные единицы. Секунды и миллисекунды выглядят одинаково, различаясь тремя разрядами. Смешение даёт даты в далёком прошлом или будущем, и это самая быстрая в обнаружении ошибка из перечисленных — потому что результат абсурден.
Хранение зоны как смещения. Смещение — это не зона. Смещение действует сейчас и меняется при переводе часов; зона — это правило, по которому смещение вычисляется. Сохранив смещение, вы сохранили следствие вместо причины.
Название зоны, которое устарело. Правила и сами названия обновляются, а база правил лежит в системе и обновляется вместе с ней. Сервер, который давно не обновляли, считает по старым правилам и расходится с телефоном пользователя. Это редкая, но крайне неприятная ошибка: код верен, данные верны, расходится справочник.
Время, пришедшее от клиента. Часы на устройстве пользователя могут быть сбиты, и метка, присланная браузером, — не факт, а мнение. Для порядка событий это опасно: сортировка по клиентскому времени даёт ленту, где ответ появляется раньше вопроса.
Что делать
Момент хранить в единой шкале и переводить при показе. Это тот случай, где привычный совет верен полностью.
Будущее намерение хранить местным временем и зоной. Отдельными полями, а не одним. Момент вычислять при необходимости, а не при сохранении.
Зону выбирать явно. Не подставлять зону сервера и не угадывать по браузеру молча. Зона — это данные пользователя, и она должна быть видна ему в настройках.
Сравнивать моменты, а не строки. Текстовое представление сортируется правильно только в одном формате и только при одинаковой зоне; в остальных случаях сортировка по строке даёт неверный порядок.
Границы отчётов определять явно. В каком часовом поясе считаются сутки — договорённость уровня продукта, и её место в документации, а не в чьей-то голове.
Длительность хранить отдельно от момента. «Через сутки» и «плюс двадцать четыре часа» — разные вещи в дни перевода часов. Если срок задан в календарных единицах, считать его надо календарно, а не прибавлением интервала.
Не изобретать разбор форматов. Дата, пришедшая строкой в неизвестном виде, разбирается библиотекой, а не выражением. Форматов записи существует много, различаются они мелочами, и самодельный разбор угадывает неверно ровно в тех случаях, которых не было в примерах.
Как это проверять
Тесты на времени, которое сейчас, проверяют только сегодняшний день. Нужны фиксированные даты: день перевода часов вперёд, день перевода назад, конец месяца, конец года, високосный день.
Отдельно стоит проверить поведение при зоне, отличной от серверной. Большая часть ошибок этого класса не воспроизводится у разработчика именно потому, что у него совпадают зона машины и зона тестовых данных.
И полезно один раз прогнать данные через смену зоны сервера. Если результаты меняются там, где не должны, — где-то в коде зона берётся неявно.
Позиция редакции
Мы считаем, что различать момент и намерение надо на уровне хранения, а не при показе, и что будущие события нельзя сохранять как момент. Практический вывод: посмотрите, как в вашей базе лежат напоминания и встречи. Если одним полем с меткой момента — вы уже согласились сдвинуть их при следующем изменении правил.
Мы неправы, если ваша система живёт в одном месте, не назначает будущих событий и не строит суточных отчётов: там достаточно одной шкалы, и вся эта осторожность — лишняя работа.