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. Но чтобы превратить это в «момент», нужно понять: это 10 утра где?

В 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
Задача
Kotlin SELF, 51 уровень, 4 лекция
Недоступна
Сдвиг дедлайна
Сдвиг дедлайна
1
Задача
Kotlin SELF, 51 уровень, 4 лекция
Недоступна
Окно отчёта
Окно отчёта
1
Задача
Kotlin SELF, 51 уровень, 4 лекция
Недоступна
Старт дня мира
Старт дня мира
1
Задача
Kotlin SELF, 51 уровень, 4 лекция
Недоступна
Длина суток
Длина суток
1
Опрос
Даты и время, 51 уровень, 4 лекция
Недоступен
Даты и время
Даты и время
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ