7 мин

Проблемы форматирования и конвертации времени в JavaScript

Разбираем частые проблемы форматирования и конвертации времени в JavaScript: секунды в hh:mm:ss, UTC vs локальное, Intl, Temporal и типичные ошибки.

Проблемы форматирования и конвертации времени в JavaScript

Какая именно проблема: длительность или дата-время

Почти все «странности» со временем в JavaScript сводятся к одной причине: вы решаете задачу про длительность, а используете инструменты для момента во времени — или наоборот. Отсюда и типовые симптомы:

  • «Съехало на час» после форматирования или на другом компьютере.
  • «Показывает не тот день» (например, вчера вместо сегодня).
  • «Получилось 00:00», хотя ожидали «01:30».

Два разных типа задач

Длительность — это «сколько прошло», например 90 минут, 5400 секунд, «2:15:00» как таймер или продолжительность видео. У длительности нет часового пояса и календаря: 5400 секунд везде одинаковы.

Дата-время (момент времени) — это «когда именно», например «2025-12-23 10:00 в Москве». Здесь неизбежны часовые пояса, переходы на летнее/зимнее время и правила локали. Один и тот же момент можно отобразить по‑разному в зависимости от timezone.

Если вы берёте число секунд «как длительность» и создаёте new Date(seconds * 1000), вы незаметно превращаете задачу в «момент времени относительно 1970-01-01», и дальше в игру вступают timezone и DST — отсюда неожиданные смещения.

О чём будет статья

Дальше разберём практические рецепты: как переводить секунды в hh:mm:ss без Date, как безопасно парсить строки дат, когда использовать Intl.DateTimeFormat, почему ISO 8601 иногда «подкладывает ловушки», и какие подходы (включая Temporal) помогают избежать сюрпризов.

Отдельный смысл это приобретает в продуктовой разработке: как только вы собираете расписания, отчёты, SLA, биллинг или любые «события по времени», ошибки в трактовке дат становятся багами уровня бизнеса. Если вы делаете такие интерфейсы быстро (например, собираете админки и пользовательские кабинеты в TakProsto.AI), полезно заранее зафиксировать правила: что считается длительностью, что — моментом времени, где UTC, а где локаль.

Термины, о которых договоримся

  • timestamp: число миллисекунд (или секунд) от эпохи Unix (1970-01-01T00:00:00Z).
  • timezone: часовой пояс (например, Europe/Moscow).
  • offset (оффсет): смещение от UTC в конкретный момент (например, +03:00).
  • ISO 8601: стандарт записи даты-времени (например, 2025-12-23T10:00:00+03:00).

Перед тем как чинить баг, ответьте на один вопрос: вы форматируете длительность или отображаете конкретный момент времени? Это определит правильный инструмент и убережёт от «съехало на час».

База: как JavaScript хранит время и почему путает вывод

Прежде чем «чинить форматирование», важно понять, что именно хранит JavaScript. Основная путаница возникает из‑за того, что момент времени (точка на шкале) и отображение этого момента (строка в какой‑то зоне и формате) — разные задачи.

Что такое timestamp и почему это просто number

В JavaScript «универсальной валютой» времени является timestamp — число миллисекунд, прошедших с начала эпохи Unix: 1970‑01‑01T00:00:00.000Z.

  • Date.now() возвращает number — миллисекунды.
  • new Date(ms) принимает это число и создаёт объект Date.

Важно: в Date внутри хранится именно timestamp (мс), а не «дата в Москве» или «время в Берлине».

Date хранит момент, а показывает по правилам временной зоны

Объект Date представляет один и тот же момент, но методы вывода могут показать его по‑разному:

  • toISOString() всегда печатает UTCZ).
  • toString(), toLocaleString() и многие «локальные» методы ориентируются на локальную временную зону пользователя/окружения.

Отсюда типичный сюрприз: вы создаёте Date из одного и того же timestamp, а на разных компьютерах видите разное «время на часах» — потому что часовой пояс разный.

UTC vs локальное время пользователя

  • UTC — единая шкала времени без смещений (удобна для хранения и обмена).
  • Локальное время — UTC плюс/минус смещение и правила (включая переходы на летнее/зимнее время, где применимо).

Если вы храните данные как timestamp/UTC, но показываете пользователю локально — это нормально. Ошибка начинается, когда вы ожидаете, что локальный вывод будет совпадать с «тем, как было введено» в другой зоне.

Почему Date «не знает» исходную зону строки без оффсета

Когда вы парсите строку даты/времени, ключевое — есть ли в ней оффсет или Z.

  • Строки вида 2025-12-23T10:00:00Z или 2025-12-23T10:00:00+03:00 однозначны: зона указана.
  • Строка без зоны (например, 2025-12-23T10:00:00) не сообщает, что это за «10:00». В результате движок интерпретирует её по своим правилам (часто как локальное время среды выполнения), и вы теряете информацию об «исходной зоне».

Практическое правило: если время должно быть однозначным при передаче между системами — передавайте timestamp или ISO 8601 со смещением/Z.

Конвертация секунд в hh:mm:ss без Date

Если у вас есть длительность в секундах (таймер, прогресс, время ролика), объект Date здесь не нужен: он про календарные даты и часовые пояса. Для длительностей проще и безопаснее собрать строку арифметикой.

Базовая функция seconds → HH:mm:ss

Ниже вариант с ведущими нулями и контролем округления. По умолчанию используем Math.floor, чтобы не «перепрыгивать» секунду раньше времени.

function pad2(n) {
  return String(n).padStart(2, '0');
}

function formatHMS(inputSeconds, { round = 'floor' } = {}) {
  const s = Number(inputSeconds);
  if (!Number.isFinite(s)) return '00:00:00';

  const r = round === 'round' ? Math.round : round === 'ceil' ? Math.ceil : Math.floor;
  const total = Math.max(0, r(s));

  const hours = Math.floor(total / 3600);
  const minutes = Math.floor((total % 3600) / 60);
  const seconds = total % 60;

  return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
}

Варианты: mm:ss и dd HH:mm:ss

Иногда часы не нужны, а иногда длительности больше суток лучше показывать с днями.

function formatMS(totalSeconds) {
  const total = Math.max(0, Math.floor(totalSeconds));
  const m = Math.floor(total / 60);
  const s = total % 60;
  return `${pad2(m)}:${pad2(s)}`;
}

function formatDHMS(totalSeconds) {
  const total = Math.max(0, Math.floor(totalSeconds));
  const days = Math.floor(total / 86400);
  const rest = total % 86400;
  const h = Math.floor(rest / 3600);
  const m = Math.floor((rest % 3600) / 60);
  const s = rest % 60;
  return days > 0 ? `${days} ${pad2(h)}:${pad2(m)}:${pad2(s)}` : `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
}

Нюанс округления и миллисекунды

Если вход — миллисекунды, сначала делите на 1000. Для таймеров обычно корректнее floor (показывать «оставшееся целое»), для измерений — round.

Тестовые примеры

formatHMS(59)      // "00:00:59"
formatHMS(60)      // "00:01:00"
formatHMS(3661)    // "01:01:01"
formatDHMS(90061)  // "1 01:01:01"

Анти‑паттерн: использовать Date для длительностей

Date в JavaScript — это инструмент для момента времени (конкретной точки на шкале времени), а не для длительности (интервала). Поэтому попытка «красиво отформатировать» длительность через new Date(seconds * 1000) почти всегда ведёт к скрытым ошибкам.

Почему new Date(seconds*1000) не форматирует длительность

Когда вы создаёте Date, вы на самом деле создаёте дату/время относительно Unix epoch: 1970-01-01T00:00:00.000Z. Дальше любые методы вроде toLocaleTimeString() или форматирование через Intl.DateTimeFormat работают как для часов на календаре: учитывают дату, временную зону, иногда DST — а вы-то хотели просто «сколько прошло времени».

Пример ошибки: переход дня при больших длительностях

Допустим, у вас длительность 27 часов:

const seconds = 27 * 3600;
const d = new Date(seconds * 1000);
console.log(d.toISOString());

Результат будет примерно таким: 1970-01-02T03:00:00.000Z. Если затем вывести только время, вы получите 03:00:00 — и потеряете факт, что прошло 27 часов, а не 3. Любая длительность больше 24 часов превращается в «время суток следующего дня», а формат времени суток день не показывает.

Пример ошибки: влияние временной зоны на «длительность»

const seconds = 3600; // 1 час
const d = new Date(seconds * 1000);
console.log(d.toLocaleTimeString());

В зависимости от временной зоны пользователя вывод может отличаться (и даже зависеть от настроек локали). Выводится «часы на циферблате», а не «прошёл 1 час». Особенно неприятно, когда берут getHours()/getMinutes() — там уже точно вмешивается локальная зона.

Правильная стратегия: считать части времени вручную

Для длительности надёжнее разложить секунды на компоненты:

  • часы = Math.floor(totalSeconds / 3600)
  • минуты = Math.floor(totalSeconds % 3600 / 60)
  • секунды = totalSeconds % 60

И затем собрать строку с ведущими нулями. Так вы получите корректный результат для 5 минут, 27 часов и 200 часов — без влияния временных зон и «переезда» через дату.

Парсинг дат: ISO 8601, оффсеты и скрытые ловушки

ISO 8601 выглядит «стандартно», но в JavaScript есть нюансы: одна и та же строка может интерпретироваться как UTC или как локальное время — и тогда на выводе вы увидите сдвиг на ваш часовой пояс.

Какие строки вы встречаете чаще всего

Вот типичные варианты и что они означают:

  • 2025-12-23только дата, без времени и без зоны.
  • 2025-12-23T10:00:00 — дата и время, но без часового пояса.
  • 2025-12-23T10:00:00Z — дата и время в UTC (суффикс Z).
  • 2025-12-23T10:00:00+03:00 — дата и время с явным оффсетом (+3 часа к UTC).

Главная ловушка: ISO «без зоны»

Самая частая ошибка — передавать 2025-12-23T10:00:00 и ожидать, что это «UTC». На практике такая строка обычно трактуется как локальное время. То есть 10:00 будет считаться «10:00 в часовом поясе пользователя», а при сравнении/переводе в UTC значение «поплывёт».

С датой без времени (2025-12-23) ситуация тоже коварная: даже при корректном парсинге вы можете неожиданно получить сдвиг при форматировании в локали (из‑за того, что момент времени всё равно будет выбран, обычно «полночь» в какой-то зоне).

Рекомендация: всегда передавайте зону явно

Если дата/время относится к конкретному моменту, храните и передавайте ISO 8601 с Z или с оффсетом:

  • сервер отдаёт UTC: 2025-12-23T07:00:00Z
  • сервер отдаёт локальный оффсет события: 2025-12-23T10:00:00+03:00

Так вы фиксируете «момент на шкале времени», а не «красивую строку».

Безопасный парсинг: Date.parse + проверка

new Date(str) внутри использует тот же механизм, что и Date.parse. Ключевое — проверять, что строка распарсилась.

function parseIsoOrThrow(input) {
  const ms = Date.parse(input);
  if (Number.isNaN(ms)) {
    throw new Error(`Invalid date string: ${input}`);
  }
  return new Date(ms);
}

parseIsoOrThrow('2025-12-23T10:00:00Z');

Если вам критично избежать неоднозначности, лучше вообще не принимать ISO без зоны и валидировать это на входе: либо Z, либо ±HH:MM.

UTC, локальное время и летнее время: что учитывать

Проверьте парсинг ISO строк
Попросите TakProsto написать валидатор ISO 8601, который принимает только Z или оффсет.

Когда вы форматируете время в JavaScript, сначала определите: это момент во времени (например, «встреча 2025‑01‑10 18:00») или длительность (например, «1 час 20 минут»). Для моментов почти всегда безопаснее хранить данные в UTC, а показывать — в локали пользователя.

Сервер хранит UTC, клиент показывает локально

Практичный подход: сервер хранит «истину» в UTC (timestamp или ISO с зоной), а UI форматирует под пользователя. Тогда один и тот же момент корректно «переедет» между часовыми поясами.

Пример: сервер отдаёт 2025-12-23T12:00:00Z или число миллисекунд. Браузер, создавая new Date(value), интерпретирует это как UTC‑момент и отображает его в локальной зоне при форматировании.

getUTC* vs get*

Важно понимать, что объект Date хранит момент (UTC), но методы чтения бывают двух типов:

  • getHours(), getFullYear() и т. п. — локальные компоненты пользователя
  • getUTCHours(), getUTCFullYear() и т. п. — UTC‑компоненты
const d = new Date('2025-12-23T12:00:00Z');

console.log(d.getHours());     // часы в локальной зоне
console.log(d.getUTCHours());  // часы в UTC

Если вы «собираете строку» вручную, перепутать эти методы — один из самых частых источников смещения.

Смещения и летнее время: почему +03:00 не всегда равно «Москва»

Оффсет вида +03:00 — это фиксированное смещение, а «Москва», Europe/Moscowчасовой пояс с правилами (исторические изменения, переходы на летнее время в прошлом и т. д.). В некоторых регионах оффсет меняется по датам, поэтому «+02:00» сегодня не гарантирует те же часы на другой дате.

Вывод: для корректного отображения по городу/региону храните IANA time zone (например, Europe/Berlin) или показывайте время в локали пользователя, не подставляя вручную оффсеты.

Рекомендации для API

Чтобы не терять контекст времени, API обычно отдаёт:

  • timestamp (миллисекунды или секунды) + явное указание единиц
  • и/или ISO 8601 со смещением/Z: 2025-12-23T12:00:00Z или 2025-12-23T15:00:00+03:00

Избегайте строк без зоны вроде 2025-12-23 12:00:00: разные клиенты могут интерпретировать их по-разному.

Форматирование вывода через Intl.DateTimeFormat

Intl.DateTimeFormat — встроенный способ красиво и предсказуемо форматировать дату/время для пользователя с учётом локали и выбранной временной зоны. Это лучше, чем собирать строку вручную через getMonth() и «плюс один», и обычно проще, чем подключать библиотеку.

Базовый пример: короткая дата и время

const date = new Date('2025-12-23T10:15:30Z');

const fmt = new Intl.DateTimeFormat('ru-RU', {
  dateStyle: 'short',
  timeStyle: 'short',
  timeZone: 'Europe/Moscow',
  hour12: false,
});

console.log(fmt.format(date)); // например: "23.12.2025, 13:15"

Здесь важно:

  • Локаль задаёт язык и формат (например, ru-RU → день.месяц.год).
  • timeZone управляет тем, в какой зоне показать момент времени. Без неё будет использована зона окружения (браузера/сервера).
  • hour12: false фиксирует 24‑часовой формат (полезно, если окружение иначе предпочитает AM/PM).

Частые требования и опции

Если вам не подходят dateStyle/timeStyle, можно указать части явно:

const fmt2 = new Intl.DateTimeFormat('ru-RU', {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
  hour: '2-digit',
  minute: '2-digit',
  second: '2-digit',
  hour12: false,
  timeZone: 'Europe/Moscow',
});

console.log(fmt2.format(new Date()));

Это удобно для требований вроде «короткая дата», «только время», «всегда секунды».

Как достать отдельные части через formatToParts

formatToParts() полезен, когда нужно, например, отдельно вывести дату и время в разных местах интерфейса или собрать свою строку без гаданий про разделители.

const parts = fmt2.formatToParts(new Date('2025-12-23T10:15:30Z'));
const map = Object.fromEntries(parts
  .filter(p => p.type !== 'literal')
  .map(p => [p.type, p.value])
);

console.log(map); // { day: "23", month: "12", year: "2025", hour: "13", minute: "15", second: "30" }

Так вы получаете ведущие нули и корректные значения с учётом локали и timeZone, не привязываясь к конкретному виду строки.

Сборка строк времени без сюрпризов (ведущие нули и локали)

Соберите таймер без Date
Соберите в TakProsto компонент hh:mm:ss из секунд без таймзон и сюрпризов.

Когда вы «склеиваете» время вручную (например, hours + ':' + minutes), ошибки появляются там, где их не ждёшь: в разных локалях порядок частей и разделители могут отличаться, а формат «как привыкли пользователи» внезапно перестаёт быть универсальным.

Ведущие нули: где чаще всего ломается вид

Для UI обычно нужен фиксированный вид HH:mm или HH:mm:ss. Самая частая проблема — минуты/секунды без ведущего нуля: 9:5 вместо 09:05.

Минимальный и понятный способ — padStart:

const hh = String(hours).padStart(2, '0');
const mm = String(minutes).padStart(2, '0');
const ss = String(seconds).padStart(2, '0');
const text = `${hh}:${mm}:${ss}`;

Но важно помнить: это «жёсткий» формат, он не учитывает локальные правила отображения.

Как избежать сюрпризов с локалями: Intl + formatToParts

Если вы хотите взять локальные разделители/порядок, но при этом контролировать конкретные части (часы, минуты, секунды), используйте formatToParts(). Он возвращает массив сегментов с типами, и вы можете собрать строку по этим типам, не полагаясь на случайный порядок.

const d = new Date(Date.UTC(2000, 0, 1, 9, 5, 0));
const fmt = new Intl.DateTimeFormat('ru-RU', {
  hour: '2-digit', minute: '2-digit', second: '2-digit',
  hourCycle: 'h23', timeZone: 'UTC'
});

const parts = fmt.formatToParts(d);
const byType = Object.fromEntries(parts.filter(p => p.type !== 'literal')
  .map(p => [p.type, p.value]));

const ui = `${byType.hour}:${byType.minute}:${byType.second}`;

Пример: единый формат UI для разных локалей

Подход выше позволяет показывать одинаковый UI-формат (например, строго HH:mm:ss) пользователям с любыми настройками системы, при этом гарантируя ведущие нули и предсказуемую сборку строки.

Temporal: современный подход к датам и длительностям

Date в JavaScript — один универсальный тип на все случаи: и «момент времени», и «локальная дата-время», и (по ошибке) «длительность». Из‑за этого легко перепутать часовые пояса, получить смещение на летнем времени или неожиданно «переехать» на предыдущий/следующий день.

Temporal — новый API, который вводит явные типы для разных задач. Это снижает количество скрытых допущений и делает код более читаемым: по типу сразу видно, что вы храните.

Что доступно сейчас

Temporal ещё не гарантирован во всех средах. На практике чаще используют polyfill:

import { Temporal } from '@js-temporal/polyfill';

Проверяйте поддержку целевой платформы (браузеры, Node.js, окружения встраивания) и решайте, готовы ли вы добавить зависимость.

Примеры: Instant, ZonedDateTime и Duration

1) Момент времени (timestamp в UTC)Temporal.Instant:

const instant = Temporal.Instant.from('2025-12-23T10:15:30Z');

2) Тот же момент, но в конкретной зонеZonedDateTime:

const zdt = instant.toZonedDateTimeISO('Europe/Moscow');
// zdt учитывает правила зоны и переходы (например, DST)

3) Длительность как длительностьTemporal.Duration (без часовых поясов и дат):

const d = Temporal.Duration.from({ seconds: 3661 }).shiftTo('hours', 'minutes', 'seconds');
// d.hours === 1, d.minutes === 1, d.seconds === 1

Когда стоит переходить на Temporal

Переход особенно оправдан, если:

  • вы регулярно работаете с часовыми поясами и важны переходы/правила зоны;
  • у вас в коде смешаны «timestamp», «локальная дата-время» и «длительность», и из‑за этого бывают баги;
  • нужно явно моделировать бизнес‑сущности вроде «дата без времени» или «время без даты».

Если же задача — просто вывести hh:mm:ss из секунд, Temporal может быть избыточным: проще держать форматирование длительностей отдельно, а Temporal подключать там, где реально важны типы и зоны.

Библиотеки дат: когда они нужны и как выбрать

Встроенный Date и Intl закрывают много задач, но иногда проще и безопаснее взять библиотеку. Главное — понимать, за что вы платите зависимостью: удобство, предсказуемость и дополнительные возможности.

Короткое сравнение: date-fns, dayjs, luxon

date-fns — набор маленьких функций. Плюс: можно импортировать точечно и держать бандл меньше. Минус: вы сами собираете «конструктор» (парсинг, форматирование, локали) и следите, чтобы везде использовались одни и те же допущения.

dayjs — компактный API в стиле Moment. Плюс: быстрый старт и знакомые методы. Минус: многие возможности подключаются плагинами, и легко получить «зоопарк» разных конфигураций в проекте.

luxon — более «концептуальный» подход (DateTime/Duration/Interval), хорошо дружит с часовыми поясами и локалями. Плюс: удобнее для сложных сценариев. Минус: обычно тяжелее по размеру и требует чуть больше дисциплины.

Когда библиотека оправдана

Она уместна, если у вас:

  • сложные часовые зоны (в т.ч. IANA-зоны) и требования к точному отображению для разных регионов;
  • календарные операции: «прибавить 1 месяц» с корректной логикой конца месяца, бизнес-календари, интервалы;
  • надёжный парсинг разных форматов входных дат (особенно от внешних систем), а не только ISO.

Риски и стоимость

Библиотеки увеличивают размер бандла, тянут локали, а иногда создают зависимость от «магии» парсинга. Еще один момент — эволюция платформы: с появлением Temporal часть задач уйдет в стандарт, и библиотеку может понадобиться постепенно вынимать.

Практичная рекомендация

Для простых задач (форматировать вывод, собрать строку времени, преобразовать секунды) старайтесь обходиться стандартными средствами. Подключайте библиотеку только там, где она убирает реальные риски и сокращает код, а не просто «чтобы было удобнее».

Чеклист диагностики и типовые причины ошибок

Заберите исходный код
Экспортируйте исходники React и Go и продолжайте развивать проект в своём репозитории.

Когда «время съезжает», сначала уточните: вы работаете с длительностью (например, 90 минут) или с моментом во времени (конкретная дата/время). Большинство багов рождается из смешения этих двух задач.

1) Проверьте входные данные (самое важное)

  • Единицы измерения: секунды или миллисекунды? Частая ошибка — передать seconds туда, где ждут milliseconds (или наоборот). Быстрый тест: 1700000000 — похоже на секунды, 1700000000000 — на миллисекунды.
  • Временная зона: известно ли, в какой зоне «живёт» значение? Для строки/таймстампа это критично.
  • Формат строки: это ISO 8601 (2025-12-23T10:15:00Z) или «человеческий» формат (23.12.2025 13:15)? Уточните, есть ли в строке оффсет (+03:00) или Z.

2) Логи, тесты и воспроизводимость

Поймайте ошибку в минимальном примере и залогируйте:

  • исходное значение;
  • результат парсинга;
  • getTime() (мс от эпохи);
  • локаль и timeZone, если форматируете через Intl.

В тестах фиксируйте окружение: задавайте явную timeZone (там, где это возможно) и добавьте набор кейсов вокруг переходов на летнее/зимнее время (DST): «за час до», «в момент перехода», «через час после».

3) Типовые причины

  • Перепутанные единицы (секунды/миллисекунды).
  • Двойное преобразование в UTC: например, вы получили UTC-таймстамп, затем «перевели в UTC» ещё раз, или вручную прибавили/вычли смещение, а потом отформатировали как локальное.
  • Парсинг не-ISO строк через Date: браузеры могут трактовать такие строки по-разному.
  • Использование Date для длительностей: Date про календарное время, поэтому длительность может «ломаться» на DST и таймзонах.

4) Советы для API и БД

  • Храните моменты времени в UTC (таймстамп или ISO с Z).
  • Отображайте на клиенте в локальной зоне пользователя (или в зоне, выбранной в настройках).
  • Для длительностей храните число секунд/миллисекунд и форматируйте без Date.

Готовые рецепты и краткие рекомендации напоследок

Ниже — небольшая «шпаргалка», которую удобно утащить в проект. Главная мысль: не смешивайте длительность и дату‑время. Длительность — это количество секунд/миллисекунд, а дата‑время — точка на шкале времени (с часовым поясом и календарём).

1) seconds → hh:mm:ss (без Date)

export function secondsToHms(totalSeconds) {
  const s = Math.max(0, Math.floor(totalSeconds));
  const hours = Math.floor(s / 3600);
  const minutes = Math.floor((s % 3600) / 60);
  const seconds = s % 60;

  const pad2 = (n) => String(n).padStart(2, '0');
  return `${hours}:${pad2(minutes)}:${pad2(seconds)}`; // 1:05:09
}

Если нужны ведущие нули и для часов — замените ${hours} на pad2(hours).

2) Форматировать дату‑время через Intl.DateTimeFormat

export function formatDateTime(ms, locale = 'ru-RU', timeZone = 'Europe/Moscow') {
  return new Intl.DateTimeFormat(locale, {
    dateStyle: 'medium',
    timeStyle: 'short',
    timeZone,
  }).format(new Date(ms));
}

Так вы явно контролируете локаль и таймзону, а не надеетесь на настройки окружения.

3) Безопасный парсинг ISO 8601

  • Строка с Z или оффсетом (+03:00) — однозначна.
  • Строка вида "2025-12-23 10:00" — неоднозначна (браузеры парсят по‑разному).
export function parseIsoOrThrow(iso) {
  if (!/\d{4}-\d{2}-\d{2}T/.test(iso)) throw new Error('Expected ISO 8601 with T');
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) throw new Error('Invalid date');
  return d;
}

Мини‑гайд по тестированию

  • Фиксируйте «сейчас»: используйте фейковые таймеры (например, vi.useFakeTimers()/jest.useFakeTimers()) и задавайте системное время.
  • Проверяйте таймзону: запускайте тесты в заданной зоне (TZ=UTC в Node) или явно передавайте timeZone в Intl.

Если вы внедряете эти правила в продуктовые сценарии (отчёты, биллинг, расписания), полезно собрать внутренний гайд в базе знаний или в /blog.

А если вы быстро собираете приложение «из чата» и сразу доводите до деплоя, хороший практический приём — зафиксировать эти правила на уровне требований и тестов ещё на этапе планирования. В TakProsto.AI для этого удобно использовать planning mode, а затем безопасно накатывать изменения через snapshots и rollback, чтобы правки в логике дат/часовых поясов не ломали продакшен. Для команд и долгих проектов также помогает экспорт исходников и предсказуемый стек (React на фронтенде, Go + PostgreSQL на бэкенде).

FAQ

Почему при конвертации секунд через new Date() время «съезжает» на час или показывает не то?

Потому что вы решаете задачу про длительность (например, 3600 секунд), а используете Date, который описывает момент во времени (точку на шкале относительно 1970-01-01T00:00:00Z). Дальше в вывод вмешиваются timezone/DST и вы видите «сдвиг». Для длительностей форматируйте арифметикой (деление/остаток), без Date.

Что именно хранит JavaScript Date и почему вывод зависит от компьютера пользователя?

Date хранит timestamp в миллисекундах от Unix epoch (UTC), а методы вывода могут интерпретировать его в локальной зоне окружения.

  • toISOString() печатает UTC.
  • toString() / toLocaleString() показывают локальное время (если явно не задан timeZone).

Из-за этого один и тот же момент на разных машинах может выглядеть по-разному.

Как правильно перевести секунды в HH:mm:ss без Date?

Потому что у длительности нет календаря и часового пояса. Надёжный способ — разложить общее число секунд на части:

const total = Math.max(0, Math.floor(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;

И затем собрать строку с padStart(2, '0'). Так результат будет одинаковым в любой timezone.

Почему длительность больше 24 часов превращается в «03:00:00» при форматировании через Date?

Это нормально для Date: 27 часов от эпохи — это уже следующий день, и если вы выводите только время суток, получаете 03:00:00 вместо «27:00:00».

Для интервалов больше 24 часов используйте формат с днями или часами без «обрезания»:

  • dd HH:mm:ss (дни + остаток)
  • или просто HH:mm:ss, где HH может быть больше 23
Почему строка ISO без зоны (например, 2025-12-23T10:00:00) — ловушка?

В строке без Z или ±HH:MM нет информации о зоне, поэтому движок обычно трактует её как локальное время среды выполнения. Это делает данные неоднозначными при обмене между системами.

Практика:

  • Передавайте ISO 8601 с Z: 2025-12-23T10:00:00Z, или
  • ISO со смещением: 2025-12-23T10:00:00+03:00, или
  • timestamp (и явно фиксируйте единицы: ms/seconds).
Как безопасно парсить дату/время в JavaScript, чтобы не получить NaN и смещения?

Минимально безопасно:

  1. Парсить через Date.parse/new Date().
  2. Проверять результат (NaN).
  3. По возможности запрещать вход без зоны (требовать Z или ±HH:MM).
const ms = Date.parse(input);
if (Number.isNaN(ms)) throw new Error('Invalid date');
const d = new Date(ms);

Если входные форматы разные и «человеческие», лучше явно описать формат (или использовать библиотеку).

В чём разница между getHours и getUTCHours, и почему из-за этого появляются баги?

getHours()/getFullYear() возвращают локальные компоненты в timezone окружения.

getUTCHours()/getUTCFullYear() возвращают UTC-компоненты.

Если вы вручную собираете строку и случайно используете локальные get*, получите сдвиги при запуске в другой зоне. Для «абсолютного» времени в UTC используйте getUTC*, а для пользовательского отображения — форматируйте через Intl с нужной timeZone.

Почему оффсет +03:00 — не то же самое, что таймзона Europe/Moscow?

+03:00 — это фиксированное смещение в конкретный момент.

Europe/MoscowIANA-таймзона с правилами (исторические изменения, возможные переходы и т. п.). На разных датах смещение у зоны может отличаться.

Если важно «как в городе/регионе», используйте IANA-зону (timeZone: 'Europe/Moscow') вместо ручных оффсетов.

Как предсказуемо форматировать дату и время под пользователя без ручной склейки строк?

Используйте Intl.DateTimeFormat и явно задавайте:

  • locale (например, ru-RU)
  • timeZone (например, Europe/Moscow или UTC)
  • нужные поля (dateStyle/timeStyle или year/month/day/hour/...)
new Intl.DateTimeFormat('ru-RU', {
  dateStyle: 'short',
  timeStyle: 'short',
  timeZone: 'Europe/Moscow',
  hour12: false,
}).format(new Date(ms));

Так вы не зависите от настроек системы пользователя.

Когда имеет смысл использовать Temporal, и что он решает по сравнению с Date?

Temporal вводит разные типы для разных задач:

  • Temporal.Instant — момент времени (аналог timestamp).
  • Temporal.ZonedDateTime — момент + IANA-таймзона.
  • Temporal.Duration — длительность (без таймзон).

Это снижает шанс перепутать «длительность» и «момент». Но поддержка ещё не везде, часто нужен polyfill (@js-temporal/polyfill). Если у вас простые таймеры hh:mm:ss, обычно достаточно арифметики без Temporal.

Похожие статьи