Пошук уроків, статей та іншого контенту
Застосуєте Date, Intl і Temporal у практичному сценарії календаря з часовими зонами та локалізованим інтерфейсом.
У цьому уроці побудуємо основу локалізованого календаря, який:
зберігає події як абсолютні моменти часу;
показує їх у часовій зоні користувача;
локалізує дату, час, назви місяців і днів тижня;
коректно працює з переходами на літній і зимовий час;
використовує Date, Intl і сучасний API Temporal;
розділяє момент часу, календарну дату та локальний час.
Головна ідея:
Подія має зберігатися як момент часу, а відображатися — у часовій зоні та локалі конкретного користувача.
Розглянемо подію:
2026-03-29T09:00:00ZСуфікс Z означає UTC. Це однозначний момент часу.
Та сама подія може відображатися по-різному:
у Києві — 29 березня 2026, 12:00;
у Нью-Йорку — 29 березня 2026, 05:00;
у Токіо — 29 березня 2026, 18:00.
Сам момент не змінюється. Змінюється лише його представлення.
У застосунку варто розрізняти:
Instant — абсолютний момент часу.
Time zone — правила перетворення моменту в локальний час.
Locale — мова, порядок компонентів дати, назви місяців і формат чисел.
Plain date/time — дата або час без прив’язки до часового поясу.
Наприклад:
2026-03-29T09:00:00Zє моментом часу, а:
29 березня 2026, 12:00 за Києвомє його локалізованим представленням.
Не можна надійно обчислювати локальний час, додаючи фіксовану кількість годин до UTC.
Наприклад, Київ може мати різницю з UTC +2 або +3 залежно від пори року. Також правила можуть змінюватися в майбутньому.
Тому замість такого коду:
const localHour = utcHour + 2;потрібно використовувати ідентифікатор часової зони:
Europe/Kyiv
America/New_York
Asia/TokyoТакі ідентифікатори описують не просто зміщення, а набір історичних і календарних правил.
Порівняння:
+02:00 — фіксоване зміщення;
Europe/Kyiv — часова зона з правилами переходів і історичними змінами.
Intl.DateTimeFormatIntl.DateTimeFormat форматують дату відповідно до локалі та часової зони.
const instant = new Date("2026-03-29T09:00:00Z");
const kyivFormatter = new Intl.DateTimeFormat("uk-UA", {
dateStyle: "full",
timeStyle: "short",
timeZone: "Europe/Kyiv",
});
const newYorkFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "full",
timeStyle: "short",
timeZone: "America/New_York",
});
console.log(kyivFormatter.format(instant));
// неділя, 29 березня 2026 р. о 12:00
console.log(newYorkFormatter.format(instant));
// Sunday, March 29, 2026 at 5:00 AMТой самий об’єкт Date відформатовано по-різному. Date зберігає момент, а Intl.DateTimeFormat визначає, як його показати.
const formatter = new Intl.DateTimeFormat("uk-UA", {
timeZone: "Europe/Kyiv",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
});
console.log(formatter.format(new Date("2026-07-26T10:15:00Z")));Часто використовувані параметри:
weekday: "long", "short", "narrow";
year: "numeric", "2-digit";
month: "long", "short", "narrow", "numeric", "2-digit";
day: "numeric", "2-digit";
hour, minute, second;
dateStyle: "full", "long", "medium", "short";
timeStyle: "full", "long", "medium", "short";
timeZone;
timeZoneName.
timeZoneЯкщо не вказати timeZone, браузер використає часову зону операційної системи користувача.
Це може бути правильно для календаря користувача, але неправильно для:
календаря офісу;
розкладу конференції;
подій, прив’язаних до певного міста;
адміністративної панелі, де всі дати мають бути в одній зоні.
Тому часову зону краще передавати явно.
Нижче наведено самодостатній приклад. Він отримує список подій, часову зону і локаль, а потім повертає локалізовані дані для відображення.
const events = [
{
id: 1,
title: "Планування спринту",
startsAt: "2026-03-29T09:00:00Z",
durationMinutes: 60,
},
{
id: 2,
title: "Демонстрація функціональності",
startsAt: "2026-03-29T15:30:00Z",
durationMinutes: 45,
},
{
id: 3,
title: "Зустріч із командою з Токіо",
startsAt: "2026-03-30T00:30:00Z",
durationMinutes: 30,
},
];
function createCalendarFormatters(locale, timeZone) {
return {
date: new Intl.DateTimeFormat(locale, {
dateStyle: "full",
timeZone,
}),
time: new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone,
}),
dateTime: new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone,
}),
month: new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone,
}),
weekday: new Intl.DateTimeFormat(locale, {
weekday: "short",
timeZone,
}),
};
}
function formatEvent(event, formatters) {
const start = new Date(event.startsAt);
const end = new Date(start.getTime() + event.durationMinutes * 60_000);
return {
id: event.id,
title: event.title,
startsAt: formatters.dateTime.format(start),
endsAt: formatters.time.format(end),
date: formatters.date.format(start),
};
}
function renderCalendar(events, {
locale = "uk-UA",
timeZone = "Europe/Kyiv",
} = {}) {
const formatters = createCalendarFormatters(locale, timeZone);
const sortedEvents = [...events]
.map((event) => ({
...event,
instant: new Date(event.startsAt),
}))
.sort((a, b) => a.instant - b.instant);
const result = {
heading: formatters.month.format(sortedEvents[0]?.instant ?? new Date()),
timeZone,
events: sortedEvents.map((event) => formatEvent(event, formatters)),
};
return result;
}
const kyivCalendar = renderCalendar(events, {
locale: "uk-UA",
timeZone: "Europe/Kyiv",
});
const tokyoCalendar = renderCalendar(events, {
locale: "ja-JP",
timeZone: "Asia/Tokyo",
});
console.log("Календар користувача з Києва:");
console.log(kyivCalendar);
console.log("Календар користувача з Токіо:");
console.log(tokyoCalendar);У цьому прикладі:
події зберігаються в UTC;
порядок подій визначається за моментом часу;
для кожного календаря використовується власна часова зона;
локаль впливає на назви місяців, порядок компонентів і формат часу;
тривалість події обчислюється в мілісекундах, але відображення виконується через Intl.
Іноді потрібно отримати окремі частини дати в певній часовій зоні: рік, місяць, день, годину.
Для цього зручно використовувати formatToParts():
const formatter = new Intl.DateTimeFormat("uk-UA", {
timeZone: "Europe/Kyiv",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
const parts = formatter.formatToParts(
new Date("2026-03-29T09:00:00Z"),
);
const values = Object.fromEntries(
parts
.filter(({ type }) => type !== "literal")
.map(({ type, value }) => [type, value]),
);
console.log(values);
// {
// day: "29",
// month: "03",
// year: "2026",
// hour: "12",
// minute: "00"
// }На відміну від Date#getHours(), цей спосіб використовує саме вказану часову зону.
Виклик:
date.getHours();повертає годину в часовій зоні середовища виконання, а не в довільній зоні.
Календар часто має показати всі події за локальний день. Тут виникає важлива проблема: локальний день не завжди триває 24 години.
Під час переходу на літній час день може мати 23 години, а під час переходу на зимовий — 25.
Тому небезпечно робити так:
const dayEnd = dayStart + 24 * 60 * 60 * 1000;Це додає 24 абсолютні години, але не обов’язково переводить нас на початок наступного локального дня.
Для складних календарних обчислень краще використовувати Temporal.
TemporalTemporal розділяє різні поняття часу на окремі типи:
Temporal.Instant — абсолютний момент;
Temporal.ZonedDateTime — момент у конкретній часовій зоні;
Temporal.PlainDate — дата без часу і часової зони;
Temporal.PlainTime — час без дати і часової зони;
Temporal.PlainDateTime — дата і час без часової зони;
Temporal.Duration — тривалість;
Temporal.TimeZone — часова зона в реалізаціях, де цей тип доступний.
На відміну від Date, об’єкти Temporal незмінні. Методи не змінюють початковий об’єкт, а повертають новий.
Підтримка
Temporalзалежить від версії браузера або середовища виконання. Перед використанням у production потрібно перевірити підтримку цільових середовищ або підключити сумісну реалізацію API.
if (!globalThis.Temporal) {
throw new Error("Це середовище не підтримує Temporal");
}
const instant = Temporal.Instant.from("2026-03-29T09:00:00Z");
const kyivTime = instant.toZonedDateTimeISO("Europe/Kyiv");
const newYorkTime = instant.toZonedDateTimeISO("America/New_York");
console.log(kyivTime.toString());
// 2026-03-29T12:00:00+03:00[Europe/Kyiv]
console.log(newYorkTime.toString());
// 2026-03-29T05:00:00-04:00[America/New_York]Temporal.Instant зберігає момент, а toZonedDateTimeISO() додає до нього календар і часову зону.
const meeting = Temporal.Instant.from("2026-03-29T09:00:00Z")
.toZonedDateTimeISO("Europe/Kyiv");
const sameMeetingInLondon = meeting.withTimeZone("Europe/London");
console.log(meeting.toString());
// 2026-03-29T12:00:00+03:00[Europe/Kyiv]
console.log(sameMeetingInLondon.toString());
// 2026-03-29T10:00:00+01:00[Europe/London]withTimeZone() не змінює момент. Він змінює лише його представлення.
Припустімо, користувач у Києві створює подію на 29 березня 2026 року о 12:00.
const localDateTime = Temporal.PlainDateTime.from(
"2026-03-29T12:00",
);
const zonedDateTime = localDateTime.toZonedDateTime({
timeZone: "Europe/Kyiv",
});
const instant = zonedDateTime.toInstant();
console.log(zonedDateTime.toString());
// дата, час і зміщення для Europe/Kyiv
console.log(instant.toString());
// абсолютний момент у UTCНа сервер доцільно передати саме instant.toString() або еквівалентне UTC-представлення. Так сервер зможе зберегти однозначний момент.
Часову зону користувача також можна зберегти окремо:
const eventPayload = {
title: "Планування спринту",
startsAt: instant.toString(),
timeZone: "Europe/Kyiv",
};Це дає змогу:
показати подію іншим користувачам у їхніх часових зонах;
відобразити початкову зону автора;
повторно відформатувати подію без втрати точності.
Під час переходу на зимовий час певна локальна година може виникнути двічі.
Наприклад, годинник може перейти з 03:59 назад на 03:00. Тоді локальний час 03:30 не однозначно визначає момент.
Під час переходу на літній час деякі локальні часи взагалі не існують. Годинник може перескочити з 02:59 на 04:00, тому 03:30 буде недійсним локальним часом.
Temporal дає змогу явно вказати політику обробки таких ситуацій:
const localDateTime = Temporal.PlainDateTime.from(
"2026-10-25T03:30",
);
const zonedDateTime = localDateTime.toZonedDateTime({
timeZone: "Europe/Kyiv",
disambiguation: "compatible",
});
console.log(zonedDateTime.toString());Основні значення disambiguation:
"compatible" — поведінка за замовчуванням;
"earlier" — вибрати раніший можливий момент;
"later" — вибрати пізніший можливий момент;
"reject" — викинути помилку для неоднозначного або неіснуючого часу.
Для фінансових, медичних або логістичних систем часто варто використовувати "reject" і просити користувача вибрати коректний час явно.
try {
const dateTime = Temporal.PlainDateTime.from(
"2026-03-29T03:30",
);
const zoned = dateTime.toZonedDateTime({
timeZone: "Europe/Kyiv",
disambiguation: "reject",
});
console.log(zoned.toString());
} catch (error) {
console.error("Вибраний час не існує в цій часовій зоні.");
}Для побудови сітки календаря потрібно отримати:
перший день місяця;
день тижня, з якого починається місяць;
кількість днів у місяці;
дні попереднього або наступного місяця для заповнення сітки.
Temporal.PlainDate добре підходить для цього, оскільки він не має часового поясу.
if (!globalThis.Temporal) {
throw new Error("Це середовище не підтримує Temporal");
}
function buildMonthGrid(year, month, locale = "uk-UA") {
const firstDay = Temporal.PlainDate.from({
year,
month,
day: 1,
});
const daysInMonth = firstDay.daysInMonth;
// ISO: понеділок має номер 1, неділя — номер 7.
const firstWeekday = firstDay.dayOfWeek;
const formatter = new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
month: "short",
});
const cells = [];
// Додаємо порожні клітинки перед першим днем місяця.
for (let index = 1; index < firstWeekday; index += 1) {
cells.push(null);
}
for (let day = 1; day <= daysInMonth; day += 1) {
const date = firstDay.with({ day });
// PlainDate не можна напряму передати в Intl.DateTimeFormat.
// Перетворюємо дату на UTC-момент лише для форматування назви.
const instant = date
.toPlainDateTime("12:00")
.toZonedDateTime("UTC")
.toInstant();
cells.push({
date: date.toString(),
label: formatter.format(instant),
weekday: date.dayOfWeek,
isToday: Temporal.Now.plainDateISO().equals(date),
});
}
return {
month: firstDay.month,
year: firstDay.year,
cells,
};
}
console.log(buildMonthGrid(2026, 3));Для самої календарної сітки PlainDate кращий за Date, оскільки клітинка місяця є саме календарною датою, а не моментом часу.
Часова зона, яку отримано від користувача або сервера, не повинна безумовно передаватися у форматер.
function isValidTimeZone(timeZone) {
try {
new Intl.DateTimeFormat("en", { timeZone }).format();
return true;
} catch {
return false;
}
}
console.log(isValidTimeZone("Europe/Kyiv"));
// true
console.log(isValidTimeZone("Invalid/Zone"));
// falseПід час валідації локалі можна застосувати аналогічний підхід:
function isValidLocale(locale) {
try {
new Intl.DateTimeFormat(locale).format();
return true;
} catch {
return false;
}
}
console.log(isValidLocale("uk-UA"));
// trueДля події зі звичайним часом початку корисна така структура:
const event = {
id: "event-42",
title: "Розбір домашнього завдання",
startsAt: "2026-03-29T09:00:00Z",
durationMinutes: 90,
createdInTimeZone: "Europe/Kyiv",
};Рекомендації:
startsAt зберігати як UTC або ISO-рядок із явним зміщенням;
тривалість зберігати окремо, якщо вона має бути сталою;
часову зону автора зберігати окремим полем, якщо вона має бізнесове значення;
не зберігати дату як локальний рядок на кшталт "29.03.2026 12:00";
не покладатися на часову зону сервера чи браузера;
не використовувати Date для дат без часу, наприклад для дня народження.
Події бувають різних типів.
Наприклад, вебінар, який починається в один абсолютний момент для всіх:
2026-03-29T09:00:00ZНаприклад, щоденне нагадування о 09:00 за часом Києва. Тут важливо зберігати:
локальний час 09:00;
часову зону Europe/Kyiv;
правило повторення.
Це не те саме, що зберегти один UTC-момент.
Наприклад:
день народження;
державне свято;
дата завершення підписки.
Для такої інформації часова зона може бути непотрібною. У Temporal для цього підходить PlainDate.
Date чи TemporalDateDate зручний, якщо потрібно:
отримати поточний момент;
передати timestamp;
порівняти абсолютні моменти;
використати усталений API платформи;
форматувати момент через Intl.
const now = new Date();
const timestamp = now.getTime();
console.log(timestamp);TemporalTemporal краще підходить для:
календарної арифметики;
роботи з часовими зонами;
розділення моментів і локальних дат;
обробки переходів на літній час;
явної роботи з неоднозначними часами;
обчислення тривалостей і періодів.
Найбезпечніша модель для календаря:
Temporal.Instant для збереженого абсолютного моменту;
Temporal.ZonedDateTime для представлення моменту в часовій зоні;
Temporal.PlainDate для клітинок календаря;
Intl.DateTimeFormat для локалізованого інтерфейсу.
Небезпечно:
new Date("2026-03-29 12:00");У такого рядка немає явної часової зони, а поведінка може залежати від середовища.
Краще:
new Date("2026-03-29T12:00:00Z");або:
new Date("2026-03-29T12:00:00+03:00");toISOString() для локального інтерфейсуdate.toISOString();завжди повертає UTC. Це корисно для передачі даних, але не для показу користувачу.
Для інтерфейсу використовуйте Intl.DateTimeFormat.
Небезпечно:
`${day}.${month}.${year} ${hour}:${minute}`;Такий формат:
не локалізується;
може мати неправильний порядок компонентів;
не враховує правила конкретної мови;
часто неправильно обробляє початкові нулі.
Краще:
new Intl.DateTimeFormat("uk-UA", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);const nextDay = new Date(current.getTime() + 86_400_000);Це додає 24 абсолютні години, але через перехід на літній або зимовий час локальна дата може змінитися не так, як очікується.
Для календарних дат використовуйте Temporal.PlainDate.add():
const date = Temporal.PlainDate.from("2026-03-28");
const nextDate = date.add({ days: 1 });
console.log(nextDate.toString());
// 2026-03-29Рядки на кшталт:
CET
EET
PSTне є хорошим універсальним форматом для збереження часової зони. Вони можуть бути неоднозначними або не містити повних правил переходу на літній час.
Краще використовувати:
Europe/Kyiv
Europe/London
America/Los_AngelesКалендарні дні та абсолютні тривалості — різні поняття.
1 день у календарі означає перехід до наступної дати.
24 години означає 86 400 секунд.
У більшості днів вони збігаються, але не під час переходів між стандартним і літнім часом.
Локаль "uk-UA" не означає часову зону "Europe/Kyiv".
Можливі комбінації:
new Intl.DateTimeFormat("uk-UA", {
timeZone: "America/New_York",
});Це український інтерфейс із часом Нью-Йорка. Локаль і часова зона — незалежні налаштування.
Для тестування календаря недостатньо перевірити лише звичайну дату в середині року.
Потрібно протестувати:
перехід на літній час;
перехід на зимовий час;
подію опівночі;
подію біля межі дня;
різні локалі;
часові зони з великими зміщеннями;
часові зони, де дата події відрізняється від дати в UTC;
некоректну назву часової зони;
неоднозначні локальні часи;
високосний рік;
останній день місяця.
Приклад перевірки форматування:
const testInstant = new Date("2026-03-29T00:30:00Z");
const zones = [
"Europe/Kyiv",
"America/New_York",
"Asia/Tokyo",
];
for (const timeZone of zones) {
const formatted = new Intl.DateTimeFormat("uk-UA", {
dateStyle: "full",
timeStyle: "short",
timeZone,
}).format(testInstant);
console.log(`${timeZone}: ${formatted}`);
}Розширте приклад календаря:
Додайте підтримку сортування подій за локальною датою.
Додайте форматування timeZoneName.
Реалізуйте побудову місячної сітки з подіями.
Покажіть одну й ту саму подію для трьох часових зон.
Додайте перевірку некоректної часової зони.
Реалізуйте створення події через Temporal.PlainDateTime.
Для неоднозначного часу використайте disambiguation: "reject".
Додайте тести для дня переходу на літній час.
Date і Temporal.Instant описують абсолютний момент часу.
Часова зона визначає, як момент перетворюється на локальні дату і час.
Локаль визначає мову та формат відображення.
Intl.DateTimeFormat потрібно використовувати замість ручного складання рядків.
Часову зону слід передавати явно, якщо календар не має використовувати зону операційної системи.
Temporal.PlainDate підходить для клітинок календаря та дат без часу.
Temporal.ZonedDateTime поєднує момент, локальний час і часову зону.
Не можна безумовно вважати, що локальна доба має рівно 24 години.
Події потрібно зберігати в однозначному форматі, бажано як UTC-момент або ISO-значення з явним зміщенням.
Для складних календарних сценаріїв Temporal безпечніший і виразніший за Date.