Покоряем временные зоны в бэкенд-разработке: от сохранения до отображения

Дату и время можно назвать одним из самых коварных аспектов разработки. Кажущаяся простота задачи оборачивается багами, которые всплывают в момент смены летнего времени, при работе с пользователями из разных регионов или при миграции исторических данных. Рассмотрим стратегию, которая избавит от головной боли при работе с временными зонами.

Почему UTC — не панацея (но всё равно обязателен)

Вопреки распространённому убеждению, хранения всех дат в UTC недостаточно. Ваша стратегия должна включать два ключевых аспекта:

  1. Хранение в UTC всегда:
    Серверы и базы данных должны оперировать исключительно UTC. Хранение времени с учётом временных зон (например, TIMESTAMP WITHOUT TIME ZONE в PostgreSQL) — прямой путь к необратимой порче данных.
sql
-- Недопустимо для хранения абсолютного времени
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')
);
  1. Сохранение оригинального часового пояса:
    Для событий, привязанных к локальному времени пользователя (например, напоминание "забрать детей из школы в 15:00"), сохраняйте сразу три компонента:
    • UTC-время
    • Идентификатор временной зоны (например, "America/New_York")
    • Смещение от UTC на момент создания записи
json
// Пример структуры для события с локальной привязкой
{
  "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 — возникает критическая ошибка часового пояса.

Решение:

javascript
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() для времени пользователя. Чем это опасно:

javascript
// Неустойчиво к настройкам браузера
const userTime = new Date().getHours(); 

// Демо: пользователь из Нью-Йорка меняет часовой пояс на Токио — 
// данные без предупреждения становятся невалидными

Восстановление контекста:

javascript
// Правильный обмен данными с бэкендом
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: опасности по умолчанию

sql
-- Без явного указания запрос возвращает время в зоне сервера
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: битва типов данных

sql
-- Используйте 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)

Пример с использованием временных заглушек:

javascript
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
});

Заключение

Работа с временными зонами требует дисциплины на всех уровнях приложения:

  1. Хранение: Только UTC + явные метаданные часового пояса при необходимости.
  2. Передача данных: UNIX timestamp или ISO 8601 с указанием смещения (2024-05-15T16:00:00+04:00).
  3. Логика: Библиотеки вместо ручных вычислений (Luxon, Moment.Timezone, date-fns-tz).
  4. Валидация: Тестирование на критических точках переводов часов.

Главная ошибка — предполагать, что пользователи будут взаимодействовать с системой в том же часовом поясе, что и сервер. Реализация описанных практик устранит целый класс ошибок, которые могли бы оставаться незамеченными месяцами. Во временной работе точность — это не роскошь, а обязательство.