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

Почти все «странности» со временем в JavaScript сводятся к одной причине: вы решаете задачу про длительность, а используете инструменты для момента во времени — или наоборот. Отсюда и типовые симптомы:
Длительность — это «сколько прошло», например 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, а где локаль.
Europe/Moscow).+03:00).2025-12-23T10:00:00+03:00).Перед тем как чинить баг, ответьте на один вопрос: вы форматируете длительность или отображаете конкретный момент времени? Это определит правильный инструмент и убережёт от «съехало на час».
Прежде чем «чинить форматирование», важно понять, что именно хранит JavaScript. Основная путаница возникает из‑за того, что момент времени (точка на шкале) и отображение этого момента (строка в какой‑то зоне и формате) — разные задачи.
В JavaScript «универсальной валютой» времени является timestamp — число миллисекунд, прошедших с начала эпохи Unix: 1970‑01‑01T00:00:00.000Z.
Date.now() возвращает number — миллисекунды.new Date(ms) принимает это число и создаёт объект Date.Важно: в Date внутри хранится именно timestamp (мс), а не «дата в Москве» или «время в Берлине».
Объект Date представляет один и тот же момент, но методы вывода могут показать его по‑разному:
toISOString() всегда печатает UTC (с Z).toString(), toLocaleString() и многие «локальные» методы ориентируются на локальную временную зону пользователя/окружения.Отсюда типичный сюрприз: вы создаёте Date из одного и того же timestamp, а на разных компьютерах видите разное «время на часах» — потому что часовой пояс разный.
Если вы храните данные как timestamp/UTC, но показываете пользователю локально — это нормально. Ошибка начинается, когда вы ожидаете, что локальный вывод будет совпадать с «тем, как было введено» в другой зоне.
Когда вы парсите строку даты/времени, ключевое — есть ли в ней оффсет или Z.
2025-12-23T10:00:00Z или 2025-12-23T10:00:00+03:00 однозначны: зона указана.2025-12-23T10:00:00) не сообщает, что это за «10:00». В результате движок интерпретирует её по своим правилам (часто как локальное время среды выполнения), и вы теряете информацию об «исходной зоне».Практическое правило: если время должно быть однозначным при передаче между системами — передавайте timestamp или ISO 8601 со смещением/Z.
Если у вас есть длительность в секундах (таймер, прогресс, время ролика), объект Date здесь не нужен: он про календарные даты и часовые пояса. Для длительностей проще и безопаснее собрать строку арифметикой.
Ниже вариант с ведущими нулями и контролем округления. По умолчанию используем 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)}`;
}
Иногда часы не нужны, а иногда длительности больше суток лучше показывать с днями.
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 в 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 выглядит «стандартно», но в 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).Самая частая ошибка — передавать 2025-12-23T10:00:00 и ожидать, что это «UTC». На практике такая строка обычно трактуется как локальное время. То есть 10:00 будет считаться «10:00 в часовом поясе пользователя», а при сравнении/переводе в UTC значение «поплывёт».
С датой без времени (2025-12-23) ситуация тоже коварная: даже при корректном парсинге вы можете неожиданно получить сдвиг при форматировании в локали (из‑за того, что момент времени всё равно будет выбран, обычно «полночь» в какой-то зоне).
Если дата/время относится к конкретному моменту, храните и передавайте ISO 8601 с Z или с оффсетом:
2025-12-23T07:00:00Z2025-12-23T10:00:00+03:00Так вы фиксируете «момент на шкале времени», а не «красивую строку».
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.
Когда вы форматируете время в JavaScript, сначала определите: это момент во времени (например, «встреча 2025‑01‑10 18:00») или длительность (например, «1 час 20 минут»). Для моментов почти всегда безопаснее хранить данные в UTC, а показывать — в локали пользователя.
Практичный подход: сервер хранит «истину» в UTC (timestamp или ISO с зоной), а UI форматирует под пользователя. Тогда один и тот же момент корректно «переедет» между часовыми поясами.
Пример: сервер отдаёт 2025-12-23T12:00:00Z или число миллисекунд. Браузер, создавая new Date(value), интерпретирует это как UTC‑момент и отображает его в локальной зоне при форматировании.
Важно понимать, что объект 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 — это фиксированное смещение, а «Москва», Europe/Moscow — часовой пояс с правилами (исторические изменения, переходы на летнее время в прошлом и т. д.). В некоторых регионах оффсет меняется по датам, поэтому «+02:00» сегодня не гарантирует те же часы на другой дате.
Вывод: для корректного отображения по городу/региону храните IANA time zone (например, Europe/Berlin) или показывайте время в локали пользователя, не подставляя вручную оффсеты.
Чтобы не терять контекст времени, API обычно отдаёт:
Z: 2025-12-23T12:00:00Z или 2025-12-23T15:00:00+03:00Избегайте строк без зоны вроде 2025-12-23 12:00:00: разные клиенты могут интерпретировать их по-разному.
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() полезен, когда нужно, например, отдельно вывести дату и время в разных местах интерфейса или собрать свою строку без гаданий про разделители.
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, не привязываясь к конкретному виду строки.
Когда вы «склеиваете» время вручную (например, 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}`;
Но важно помнить: это «жёсткий» формат, он не учитывает локальные правила отображения.
Если вы хотите взять локальные разделители/порядок, но при этом контролировать конкретные части (часы, минуты, секунды), используйте 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-формат (например, строго HH:mm:ss) пользователям с любыми настройками системы, при этом гарантируя ведущие нули и предсказуемую сборку строки.
Date в JavaScript — один универсальный тип на все случаи: и «момент времени», и «локальная дата-время», и (по ошибке) «длительность». Из‑за этого легко перепутать часовые пояса, получить смещение на летнем времени или неожиданно «переехать» на предыдущий/следующий день.
Temporal — новый API, который вводит явные типы для разных задач. Это снижает количество скрытых допущений и делает код более читаемым: по типу сразу видно, что вы храните.
Temporal ещё не гарантирован во всех средах. На практике чаще используют polyfill:
import { Temporal } from '@js-temporal/polyfill';
Проверяйте поддержку целевой платформы (браузеры, Node.js, окружения встраивания) и решайте, готовы ли вы добавить зависимость.
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
Переход особенно оправдан, если:
Если же задача — просто вывести hh:mm:ss из секунд, Temporal может быть избыточным: проще держать форматирование длительностей отдельно, а Temporal подключать там, где реально важны типы и зоны.
Встроенный Date и Intl закрывают много задач, но иногда проще и безопаснее взять библиотеку. Главное — понимать, за что вы платите зависимостью: удобство, предсказуемость и дополнительные возможности.
date-fns — набор маленьких функций. Плюс: можно импортировать точечно и держать бандл меньше. Минус: вы сами собираете «конструктор» (парсинг, форматирование, локали) и следите, чтобы везде использовались одни и те же допущения.
dayjs — компактный API в стиле Moment. Плюс: быстрый старт и знакомые методы. Минус: многие возможности подключаются плагинами, и легко получить «зоопарк» разных конфигураций в проекте.
luxon — более «концептуальный» подход (DateTime/Duration/Interval), хорошо дружит с часовыми поясами и локалями. Плюс: удобнее для сложных сценариев. Минус: обычно тяжелее по размеру и требует чуть больше дисциплины.
Она уместна, если у вас:
Библиотеки увеличивают размер бандла, тянут локали, а иногда создают зависимость от «магии» парсинга. Еще один момент — эволюция платформы: с появлением Temporal часть задач уйдет в стандарт, и библиотеку может понадобиться постепенно вынимать.
Для простых задач (форматировать вывод, собрать строку времени, преобразовать секунды) старайтесь обходиться стандартными средствами. Подключайте библиотеку только там, где она убирает реальные риски и сокращает код, а не просто «чтобы было удобнее».
Когда «время съезжает», сначала уточните: вы работаете с длительностью (например, 90 минут) или с моментом во времени (конкретная дата/время). Большинство багов рождается из смешения этих двух задач.
seconds туда, где ждут milliseconds (или наоборот). Быстрый тест: 1700000000 — похоже на секунды, 1700000000000 — на миллисекунды.2025-12-23T10:15:00Z) или «человеческий» формат (23.12.2025 13:15)? Уточните, есть ли в строке оффсет (+03:00) или Z.Поймайте ошибку в минимальном примере и залогируйте:
getTime() (мс от эпохи);timeZone, если форматируете через Intl.В тестах фиксируйте окружение: задавайте явную timeZone (там, где это возможно) и добавьте набор кейсов вокруг переходов на летнее/зимнее время (DST): «за час до», «в момент перехода», «через час после».
Date: браузеры могут трактовать такие строки по-разному.Date для длительностей: Date про календарное время, поэтому длительность может «ломаться» на DST и таймзонах.Z).Date.Ниже — небольшая «шпаргалка», которую удобно утащить в проект. Главная мысль: не смешивайте длительность и дату‑время. Длительность — это количество секунд/миллисекунд, а дата‑время — точка на шкале времени (с часовым поясом и календарём).
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).
Intl.DateTimeFormatexport function formatDateTime(ms, locale = 'ru-RU', timeZone = 'Europe/Moscow') {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
}).format(new Date(ms));
}
Так вы явно контролируете локаль и таймзону, а не надеетесь на настройки окружения.
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 на бэкенде).
Потому что вы решаете задачу про длительность (например, 3600 секунд), а используете Date, который описывает момент во времени (точку на шкале относительно 1970-01-01T00:00:00Z). Дальше в вывод вмешиваются timezone/DST и вы видите «сдвиг». Для длительностей форматируйте арифметикой (деление/остаток), без Date.
Date хранит timestamp в миллисекундах от Unix epoch (UTC), а методы вывода могут интерпретировать его в локальной зоне окружения.
toISOString() печатает UTC.toString() / toLocaleString() показывают локальное время (если явно не задан timeZone).Из-за этого один и тот же момент на разных машинах может выглядеть по-разному.
Потому что у длительности нет календаря и часового пояса. Надёжный способ — разложить общее число секунд на части:
const total = Math.max(0, .(seconds));
h = .(total / );
m = .((total % ) / );
s = total % ;
Это нормально для Date: 27 часов от эпохи — это уже следующий день, и если вы выводите только время суток, получаете 03:00:00 вместо «27:00:00».
Для интервалов больше 24 часов используйте формат с днями или часами без «обрезания»:
В строке без Z или ±HH:MM нет информации о зоне, поэтому движок обычно трактует её как локальное время среды выполнения. Это делает данные неоднозначными при обмене между системами.
Практика:
Минимально безопасно:
Date.parse/new Date().NaN).getHours()/getFullYear() возвращают локальные компоненты в timezone окружения.
getUTCHours()/getUTCFullYear() возвращают UTC-компоненты.
Если вы вручную собираете строку и случайно используете локальные , получите сдвиги при запуске в другой зоне. Для «абсолютного» времени в UTC используйте , а для пользовательского отображения — форматируйте через с нужной .
+03:00 — это фиксированное смещение в конкретный момент.
Europe/Moscow — IANA-таймзона с правилами (исторические изменения, возможные переходы и т. п.). На разных датах смещение у зоны может отличаться.
Если важно «как в городе/регионе», используйте IANA-зону (timeZone: 'Europe/Moscow') вместо ручных оффсетов.
Используйте Intl.DateTimeFormat и явно задавайте:
Temporal вводит разные типы для разных задач:
Temporal.Instant — момент времени (аналог timestamp).Temporal.ZonedDateTime — момент + IANA-таймзона.Temporal.Duration — длительность (без таймзон).Это снижает шанс перепутать «длительность» и «момент». Но поддержка ещё не везде, часто нужен polyfill (). Если у вас простые таймеры , обычно достаточно арифметики без Temporal.
И затем собрать строку с padStart(2, '0'). Так результат будет одинаковым в любой timezone.
dd HH:mm:ss (дни + остаток)HH:mm:ss, где HH может быть больше 23Z: 2025-12-23T10:00:00Z, или2025-12-23T10:00:00+03:00, илиZ±HH:MMconst ms = Date.parse(input);
if (Number.isNaN(ms)) throw new Error('Invalid date');
const d = new Date(ms);
Если входные форматы разные и «человеческие», лучше явно описать формат (или использовать библиотеку).
get*getUTC*IntltimeZonelocale (например, 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));
Так вы не зависите от настроек системы пользователя.
@js-temporal/polyfillhh:mm:ss