JavaRush /Курси /Kotlin SELF /Календарна арифметика й часові зони: DatePeriod і початок...

Календарна арифметика й часові зони: DatePeriod і початок дня

Kotlin SELF
Рівень 51 , Лекція 4
Відкрита

1. Календар і часова шкала

Коли ви вперше починаєте працювати з датами, дуже хочеться думати, що дата й час — це одне й те саме, просто «з різною точністю». Але в програмуванні це дві різні осі сенсу: календар відповідає на запитання «який день?», а шкала часу — «у який момент?». Якщо цього не розрізняти, ви отримаєте баги, які проявлятимуться лише двічі на рік (і, як на зло, у бойовому середовищі).

Уявімо дві типові вимоги:

  • Якщо ви робите «оплатити до 2026-02-01», це календарна дата. Тут підходить LocalDate, тому що важливий саме день у календарі, а не секунди, хвилини чи часова зона.
  • Якщо ви робите «подія сталася о 2026-02-01T10:15:30Z», це момент часу. Тут потрібен Instant, тому що важлива точка на часовій шкалі — однакова для всіх.

Головна ідея лекції в одній фразі: календарні зсуви робимо через LocalDate + DatePeriod, а тривалість між моментами вимірюємо через Instant і Duration. І так, це різні речі — навіть якщо людині здається, що «день — це 24 години».

2. DatePeriod і Duration

Якщо Duration — це «скільки часу минуло» в секундах/мілісекундах/годинах, то DatePeriod — це «на скільки зсунути календар» у роках/місяцях/днях. Тут важливо не просто вивчити назви, а відчути різницю: це дві різні «математики».

  • У Duration світ «рівний»: 1 година — це завжди 60 хвилин, а 1 хвилина — завжди 60 секунд. Це як лінійка.
  • У календаря світ «нерівний»: у місяці може бути 28, 29, 30 або 31 день. Плюс є переходи часових поясів і DST (літній/зимовий час), через які конкретна календарна «дата + час» може поводитися несподівано. Це як лінійка, що інколи перетворюється на гармошку.

Корисно тримати в голові ось таку табличку:

Задача Правильний тип Правильна «арифметика»
«Скільки тривало виконання?»
Instant
end - start → Duration
«Показати витрати за останні 7 днів (календарно)»
LocalDate
today - DatePeriod(days = 6)
«На 1 місяць продовжити підписку»
LocalDate
date + DatePeriod(months = 1)
«Через 24 години надіслати сповіщення»
Instant
instant + 24.hours (через Duration)

Невелика, але важлива ремарка про стиль коду. Коли ви пишете функції, Kotlin заохочує читабельні сигнатури й акуратне форматування багаторядкових параметрів. Це знижує ймовірність «помилки на один аргумент» і робить код дружнішим для вас у майбутньому. Такий стиль добре узгоджується з офіційними домовленостями щодо форматування функцій.

3. Календарні зсуви в LocalDate

Коли ви хочете зробити «плюс один день» або «мінус два місяці», правильний інструмент — DatePeriod. У kotlinx.datetime (на рівні використання в курсі) це виглядає дуже приємно: ви створюєте DatePeriod(...) і додаєте або віднімаєте його від LocalDate.

Почнімо з найпростішого: плюс один день.

import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate

fun main() {
    val d = LocalDate.parse("2026-01-31")
    val next = d + DatePeriod(days = 1)

    println(d)       // 2026-01-31
    println(next)    // 2026-02-01
}

Тут працює зрозуміла «календарна логіка»: 31 січня + 1 день = 1 лютого. Жодних годин, хвилин і запитань на кшталт «а скільки секунд у цій добі?».

Тепер — «мінус тиждень» (календарний тиждень — це просто 7 днів у календарі):

import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate

fun main() {
    val d = LocalDate.parse("2026-01-14")
    val weekAgo = d - DatePeriod(days = 7)

    println(weekAgo) // 2026-01-07
}

А от із місяцями починається те, заради чого календарну арифметику взагалі виділяють в окремий інструмент.

import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate

fun main() {
    val d = LocalDate.parse("2026-01-31")
    val nextMonth = d + DatePeriod(months = 1)

    println(nextMonth) // (результат залежить від правил календаря)
}

Чому тут я не написав коментар із точною датою? Тому що важливіше зрозуміти сенс: «плюс місяць» — це не «плюс 30 днів». Це «перейти в наступний місяць, зберігши число настільки, наскільки це можливо за правилами календаря». На практиці саме тут ви маєте чітко розуміти вимоги задачі: бізнес може хотіти «останній день наступного місяця», «те саме число, якщо воно існує» або «зсув на 30 днів» — і це три різні логіки.

Дні й тривалість: два різні сенси

Коли ви пишете програму, мозок часто робить підступну підміну: щойно ви бачите «за 1 день», рука тягнеться до Duration. Бо «день» звучить як 24 години. Але для календаря «день» — це зміна дати, а не 24*60*60 секунд.

  • Сценарій «дедлайн до кінця завтрашнього дня» майже завжди календарний: ви хочете, щоб це було «завтра» за календарем — незалежно від того, скільки годин у добі в користувача (підказка: інколи не 24).
  • Сценарій «почекати добу й зробити запит» — це тривалість. Там справді доречно Duration.

Щоб краще відчути різницю, порівняймо дві функції: одна робить «плюс 7 календарних днів», інша — «плюс 7 * 24 години».

import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate

fun add7CalendarDays(date: LocalDate): LocalDate {
    return date + DatePeriod(days = 7)
}
import kotlinx.datetime.Instant
import kotlin.time.Duration.Companion.days

fun add7x24Hours(instant: Instant): Instant {
    return instant + 7.days
}

Обидві функції корисні. Просто вони відповідають на різні запитання. Якщо їх переплутати, ви отримаєте баг, який проявлятиметься «інколи». А «інколи» в програмуванні зазвичай означає «користувач обовʼязково це спіймає».

4. Початок дня й переведення в Instant

Щойно ви хочете зробити з календарної дати (LocalDate) момент часу (Instant), ви зобовʼязані обрати часову зону. Бо «2026-01-14 00:00» у Токіо й «2026-01-14 00:00» у Нью-Йорку — це різні моменти на часовій шкалі.

У побутовому мовленні ми кажемо «початок дня» так, ніби це універсальна точка. У програмуванні це не універсальна точка, а правило: «у цій зоні початок цього календарного дня відповідає такому-то моменту».

У kotlinx.datetime для цього є зручна функція: atStartOfDayIn(timeZone).

import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn

fun main() {
    val date = LocalDate.parse("2026-01-14")

    val startUtc = date.atStartOfDayIn(TimeZone.UTC)
    println(startUtc) // 2026-01-14T00:00:00Z
}

Зверніть увагу, як це працює: LocalDate сам по собі не містить часу доби, а atStartOfDayIn(...) каже: «Гаразд, вважаємо, що початок дня — це 00:00 у цій зоні, і переводимо в Instant».

Дуже корисно уявляти це так:

flowchart LR
    A["LocalDate: 2026-01-14
календарна дата"] -->|"atStartOfDayIn(TimeZone)"| B["Instant
момент часу на шкалі"] B -->|"toLocalDateTime(TimeZone)"| C["LocalDateTime
локальний час"]

Головний сенс тут такий: TimeZone — це міст. Без цього мосту ви стоїте на березі й сумуєте (або пишете милиці — а потім сумуєте ще сильніше).

Із LocalDateTime у Instant

Є поширена ситуація: ви отримали від користувача локальний «час на стіні», наприклад 2026-01-14T10:00:00. Це LocalDateTime. Але щоб перетворити це на «момент», треба зрозуміти: це десята ранку де саме?

У kotlinx.datetime перетворення робиться так: localDateTime.toInstant(timeZone).

import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant

fun main() {
    val ldt = LocalDateTime.parse("2026-01-14T10:00:00")

    val instantUtc = ldt.toInstant(TimeZone.UTC)
    println(instantUtc) // 2026-01-14T10:00:00Z
}

Якщо ви підставите системну зону, отримаєте момент, що відповідає «10:00 за місцевими правилами машини, на якій працює програма».

import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant

fun main() {
    val ldt = LocalDateTime.parse("2026-01-14T10:00:00")
    val tz = TimeZone.currentSystemDefault()

    val instant = ldt.toInstant(tz)
    println(instant) // залежить від tz
}

Чому це важливо? Тому що «системна зона» — це прихована залежність. Сьогодні програму запущено на вашому ноутбуці, завтра — на сервері в іншій зоні, післязавтра — у користувача у відрядженні. Якщо зона впливає на сенс, краще передавати TimeZone явно параметром, а не сподіватися, що «якось само».

DST і чому доба не завжди 24 години

Ця частина потрібна не для того, щоб ви стали експертом із часових поясів. Вона потрібна, щоб ви перестали вважати час лінійним там, де він насправді нелінійний.

DST (літній/зимовий час) — це ситуація, коли в деяких регіонах у певний день годинник «перескакує» вперед або назад. Через це в локальному часі можуть існувати «короткі доби», де зникає година, і «довгі доби», де година повторюється.

Що це означає на практиці? Якщо ви берете LocalDate, робите atStartOfDayIn(tz) для двох сусідніх дат і віднімаєте їх як Instant, ви можете отримати Duration, що не дорівнює рівно 24 годинам. І це нормально, бо календар живе за місцевими правилами.

У межах курсу нам достатньо запамʼятати правило: якщо ви хочете «наступний календарний день», використовуйте LocalDate + DatePeriod(days = 1); якщо ви хочете «24 години», використовуйте Instant + 24.hours. Не намагайтеся замінити одне іншим — це як намагатися замінити «перейти на наступну сторінку» на «прогорнути 300 пікселів»: інколи результат схожий, але сенс інший.

Коли «початок дня» справді потрібен

Зараз може виникнути запитання: якщо ми зберігаємо витрати як LocalDate, навіщо нам узагалі atStartOfDayIn() і переведення в Instant? Зазвичай потреба в цьому зʼявляється тоді, коли ви починаєте робити «справжнє» сортування й задавати межі.

Уявімо, що ви захотіли експортувати витрати в лог, де все зберігається як моменти часу (наприклад, для синхронізації або аудиту). Або ви захотіли порівняти витрати «строго починаючи з півночі» в конкретній зоні. Тоді «дата» має стати «моментом» — і саме тут зʼявляється правило початку дня.

Наприклад, зробімо функцію: «отримати момент початку дня витрати» у заданій зоні.

import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn

fun expenseStartInstant(expense: Expense, timeZone: TimeZone): Instant {
    return expense.date.atStartOfDayIn(timeZone)
}

І далі можна сортувати витрати за цими моментами (хоча для LocalDate сортування зазвичай і так просте). Сенс у тому, що ви робите залежність від зони явною й не намагаєтеся «вгадати», де саме ця північ.

5. Практичний приклад: дати в BudgetBuddy

Зробімо практичний (і доволі життєвий) крок: додамо «календарну дату операції» в наш навчальний консольний застосунок для обліку витрат. Нехай він у нас називається скромно й без пафосу: BudgetBuddy. У попередніх розділах курсу в нас уже є модель витрати, список витрат, команди й звіти. Тепер ми хочемо, щоб витрата була не лише «сума й категорія», а ще й «у який день».

Модель: витрата з LocalDate

Почнімо з простої моделі. Зверніть увагу: ми використовуємо LocalDate, бо для витрат у побутовому сенсі зазвичай важливий календарний день («я витратив гроші 14 січня»), а не точний Instant.

import kotlinx.datetime.LocalDate

data class Expense(
    val id: Int,
    val amountCents: Int,
    val category: String,
    val date: LocalDate,
)

Так, ми зберігаємо гроші в центах (Int), щоб не сперечатися з Double про те, де в нього «кома» (у Double вона зазвичай опиняється не там, де потрібно).

«Сьогодні» як LocalDate

Нам потрібен «сьогоднішній день» як календарна дата. Найпряміший шлях — взяти Instant і перевести його в LocalDate через системну зону.

import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime

fun todayLocalDate(timeZone: TimeZone): kotlinx.datetime.LocalDate {
    val now = Clock.System.now()
    return now.toLocalDateTime(timeZone).date
}

Тут є важлива думка: навіть «сьогодні» залежить від часової зони. Коли в одній зоні вже 14 січня, в іншій ще 13-те. Тому ми приймаємо timeZone параметром і робимо залежність явною.

Безпечний розбір введеної дати

Тепер додамо функцію читання дати. Користувач вводить рядок у форматі ISO yyyy-MM-dd (бо LocalDate.parse його розуміє), а ми повертаємо LocalDate?.

import kotlinx.datetime.LocalDate

fun parseLocalDateOrNull(input: String): LocalDate? {
    val prepared = input.trim()
    return runCatching { LocalDate.parse(prepared) }.getOrNull()
}

Контракт простий: або дата коректна, або null. Жодних падінь програми лише тому, що хтось увів 2026-13-40.

Дата витрати: порожньо → беремо «сьогодні»

Зберемо фрагмент логіки додавання. Тут ми використовуємо «guard clauses» (ранні виходи), щоб код читався лінійно. І так: за стилем такі функції зазвичай краще форматувати акуратно, особливо якщо параметри починають розростатися. Це відповідає загальним домовленостям щодо Kotlin-коду.

import kotlinx.datetime.TimeZone

fun readExpenseDateOrToday(timeZone: TimeZone): kotlinx.datetime.LocalDate {
    print("Дата (yyyy-MM-dd), порожньо = сьогодні: ")
    val s = readln().trim()

    if (s.isEmpty()) return todayLocalDate(timeZone)

    return parseLocalDateOrNull(s) ?: todayLocalDate(timeZone)
}

Так, тут є компроміс: якщо дата неправильна — ми мовчки беремо сьогодні. У реальному продукті краще повідомити користувача про помилку, але для навчального прикладу це нормально: ми зосереджуємося на типах і арифметиці.

Звіт «останні 7 календарних днів»

Тепер найцікавіше: хочемо вивести витрати за останні 7 днів, включно із сьогодні. Це чиста календарна логіка: «сьогодні» і «шість днів тому».

import kotlinx.datetime.DatePeriod
import kotlinx.datetime.TimeZone

fun last7DaysRange(timeZone: TimeZone): ClosedRange<kotlinx.datetime.LocalDate> {
    val end = todayLocalDate(timeZone)
    val start = end - DatePeriod(days = 6)
    return start..end
}

А тепер застосуймо діапазон до списку.

import kotlinx.datetime.TimeZone

fun filterByDateRange(
    expenses: List<Expense>,
    timeZone: TimeZone,
): List<Expense> {
    val range = last7DaysRange(timeZone)
    return expenses.filter { it.date in range }
}

Зверніть увагу: LocalDate порівнюється календарно, а діапазон start..end читається дуже по-людськи.

6. Типові помилки

Помилка № 1: робити календарні зсуви через Duration.
Дуже часто новачок бачить «плюс один день» і пише + 24.hours. Інколи це збігається з очікуваннями, але семантика тут зовсім інша: ви зсуваєте момент часу на лінійній шкалі. Якщо в задачі потрібен «наступний календарний день», використовуйте LocalDate + DatePeriod(days = 1), інакше почнуть спливати ефекти зон і DST.

Помилка № 2: вважати, що «місяць» — це «30 днів».
У календарі немає такого правила. Місяці різної довжини, а інколи ще й лютий із сюрпризом. Якщо за вимогами потрібно «за місяць», використовуйте DatePeriod(months = 1) і уточніть бізнес-правило для дат на кшталт 29/30/31 числа (що робити, якщо такого дня в наступному місяці немає).

Помилка № 3: намагатися отримати Instant із LocalDate або LocalDateTime без часової зони.
LocalDate і LocalDateTime не містять інформації про зону, а отже не можуть однозначно стати «моментом». Якщо вам потрібен Instant, обирайте зону явно: date.atStartOfDayIn(tz) або ldt.toInstant(tz). Це не занудство, а захист від прихованих багів.

Помилка № 4: використовувати TimeZone.currentSystemDefault() як «магічну константу» всюди.
Системна зона — це залежність оточення: вона змінюється між компʼютерами й серверами. Якщо зона важлива для сенсу (наприклад, звіт має будуватися «за часом користувача»), передавайте TimeZone параметром. Так ви зробите код тестованим і передбачуваним.

Помилка № 5: змішувати в одній змінній «дату» і «момент часу».
Наприклад, зберігати дату витрати як рядок "2026-01-14T00:00:00Z" і потім намагатися відрізати substring(0, 10) «щоб отримати дату». Це повертає вас у світ рядкових милиць. Якщо сутність — календарна дата, зберігайте LocalDate. Якщо сутність — момент, зберігайте Instant.

Помилка № 6: очікувати, що різниця між початком сусідніх днів завжди дорівнює 24 годинам.
У локальних зонах через переходи часу бувають дні, які «коротші» або «довші» на годину. Це нормально. Тому не використовуйте «початок дня + 24 години» як універсальну формулу «наступного дня». Для календаря використовуйте DatePeriod, для тривалості — Duration.

1
Опитування
Дати й час, рівень 51, лекція 4
Недоступний
Дати й час
Дати й час
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ