Дату и время можно назвать одним из самых коварных аспектов разработки. Кажущаяся простота задачи оборачивается багами, которые всплывают в момент смены летнего времени, при работе с пользователями из разных регионов или при миграции исторических данных. Рассмотрим стратегию, которая избавит от головной боли при работе с временными зонами.
Почему UTC — не панацея (но всё равно обязателен)
Вопреки распространённому убеждению, хранения всех дат в UTC недостаточно. Ваша стратегия должна включать два ключевых аспекта:
- Хранение в UTC всегда:
Серверы и базы данных должны оперировать исключительно UTC. Хранение времени с учётом временных зон (например,TIMESTAMP WITHOUT TIME ZONE
в PostgreSQL) — прямой путь к необратимой порче данных.
-- Недопустимо для хранения абсолютного времени
CREATE TABLE events (
id SERIAL PRIMARY KEY,
event_time TIMESTAMP WITHOUT TIME ZONE -- Антипаттерн!
);
-- Корректно: используем тип с привязкой к часовому поясу
CREATE TABLE events (
id SERIAL PRIMARY KEY,
event_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')
);
- Сохранение оригинального часового пояса:
Для событий, привязанных к локальному времени пользователя (например, напоминание "забрать детей из школы в 15:00"), сохраняйте сразу три компонента:- UTC-время
- Идентификатор временной зоны (например, "America/New_York")
- Смещение от UTC на момент создания записи
// Пример структуры для события с локальной привязкой
{
"title": "Родительское собрание",
"local_time": "15:00",
"timezone": "Europe/Moscow",
"utc_time": "2024-10-01T12:00:00Z",
"utc_offset": "+03:00"
}
Реальные проблемы и их решения
Годичный фантом: перевод часов
Когда пользователь создаёт событие 25 октября 2024 (перед переходом на зимнее время в Москве), а система вычисляет UTC-время исходя из UTC+3, но при повторе события 25 октября 2025 (после перехода) смещение становится UTC+2 — возникает критическая ошибка часового пояса.
Решение:
const { DateTime } = require('luxon');
function calculateRecurringEvent(startLocalTime, timezone, recurrenceRule) {
const baseDate = DateTime.fromISO(`${startLocalTime}`, {
zone: timezone
});
// Следующее повторение в контексте часового пояса
const nextOccurrence = baseDate.plus({ days: recurrenceRule.interval });
// Сохраняем с привязкой к смещению НА МОМЕНТ СОБЫТИЯ
return {
utc_time: nextOccurrence.toUTC().toISO(),
stored_offset: nextOccurrence.offset // Фиксируем активное смещение
};
}
Фронтенд: где вёрстка ломает логику
Распространённая ошибка — использовать new Date()
для времени пользователя. Чем это опасно:
// Неустойчиво к настройкам браузера
const userTime = new Date().getHours();
// Демо: пользователь из Нью-Йорка меняет часовой пояс на Токио —
// данные без предупреждения становятся невалидными
Восстановление контекста:
// Правильный обмен данными с бэкендом
async function fetchUserSchedule() {
const response = await fetch('/api/schedule', {
headers: {
// Явно передаём клиентский часовой пояс
'X-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone
}
});
const events = await response.json();
// Конвертация на клиенте
events.forEach(event => {
event.localTime = new Date(event.utc_time).toLocaleString('ru-RU', {
timeZone: event.timezone,
hour: 'numeric',
minute: '2-digit'
});
});
}
Базы данных: тонкие настройки
PostgreSQL: опасности по умолчанию
-- Без явного указания запрос возвращает время в зоне сервера
SET timezone = 'UTC'; -- Принудительно для сессии
SELECT event_time AT TIME ZONE 'UTC' as utc_time,
event_time AT TIME ZONE 'Europe/Berlin' as berlin_time
FROM events;
MySQL: битва типов данных
-- Используйте TIMESTAMP для автоматической конвертации в UTC при сохранении
-- и обратно в часовой пояс сессии при выборке
CREATE TABLE webinars (
id INT PRIMARY KEY,
starts_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Для событий с фиксированным временем (например, начало мероприятия в Нью-Йорке)
-- применяйте DATETIME с явным хранением временной зоны
CREATE TABLE global_events (
id INT PRIMARY KEY,
new_york_time DATETIME,
timezone VARCHAR(32) DEFAULT 'America/New_York'
);
Тестирование: ловим крайние случаи
Юнит-тесты должны включать:
- Переходы на летнее/зимнее время
- Зоны со сдвигом не в целых часах (Ньюфаундленд — UTC-03:30)
- Исторические изменения (Самоа перешла через линию перемены дат в 2011)
Пример с использованием временных заглушек:
test('should handle DST transition', () => {
jest.useFakeTimers()
.setSystemTime(new Date('2024-03-31T01:59:00Z'));
// Запрос за мгновение до перевода часов в Берлине (CET→ CEST)
const preDST = formatUserTime('2024-03-31T01:59:00Z', 'Europe/Berlin');
jest.setSystemTime(new Date('2024-03-31T03:01:00Z'));
const postDST = formatUserTime('2024-03-31T03:01:00Z', 'Europe/Berlin');
expect(preDST).toBe("02:59"); // GMT+1
expect(postDST).toBe("05:01"); // GMT+2
});
Заключение
Работа с временными зонами требует дисциплины на всех уровнях приложения:
- Хранение: Только UTC + явные метаданные часового пояса при необходимости.
- Передача данных: UNIX timestamp или ISO 8601 с указанием смещения (
2024-05-15T16:00:00+04:00
). - Логика: Библиотеки вместо ручных вычислений (Luxon, Moment.Timezone, date-fns-tz).
- Валидация: Тестирование на критических точках переводов часов.
Главная ошибка — предполагать, что пользователи будут взаимодействовать с системой в том же часовом поясе, что и сервер. Реализация описанных практик устранит целый класс ошибок, которые могли бы оставаться незамеченными месяцами. Во временной работе точность — это не роскошь, а обязательство.