Skip to content

Instantly share code, notes, and snippets.

@DanielRendox
Last active August 14, 2023 20:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DanielRendox/e0050c5724c4cd4808b7206f28ff9726 to your computer and use it in GitHub Desktop.
Save DanielRendox/e0050c5724c4cd4808b7206f28ff9726 to your computer and use it in GitHub Desktop.
How to properly use kotlinx-datetime library. Playground, notes, and Q&As. Not an official documentation.
/*
MIT License
Copyright (c) 2023 Daniel Rendox
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This file includes code that is licensed under Apache License Version 2.0
*/
import kotlinx.datetime.Clock
import kotlinx.datetime.FixedOffsetTimeZone
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
/*
Disclaimer: This is not an official documentation for kotlinx-datetime library. And it may contain mistakes.
*/
/**
* Local time is not local at all. A more fitting term would be "clock time." Meaning that
* it will not change when the DST gets introduced or revoked.
* It will be local only if you make it local, although it never stores [TimeZone] info.
*/
fun m1() {
val utcTimeInstant = Instant.parse("2023-08-07T01:00:00Z") // z means zero offset (UTC+0 or simply UTC)
val utcClockTime = utcTimeInstant.toLocalDateTime(TimeZone.UTC)
println(utcClockTime) // 2023-08-07T01:00
}
/**
* I used to think that [TimeZone.currentSystemDefault] somehow updates itself or
* a special method gets called on a device when a DST gets introduced/revoked.
* It doesn't. [TimeZone] only defines the rules for time conversion.
*
* So if you create two [Instant]s, one observing DST and the other not observing
* DST and convert them two the local time, you'll notice that extra hour difference.
* Whereas the current time zone rules stay the same.
*
* However, someone will update these rules if any country changes them.
*/
fun m2() {
val currentTimeZone = TimeZone.currentSystemDefault()
val summerTime = Instant.parse("2023-08-07T01:00:00Z")
val winterTime = Instant.parse("2023-12-07T01:00:00Z")
val localSummerTime = summerTime.toLocalDateTime(currentTimeZone)
val localWinterTime = winterTime.toLocalDateTime(currentTimeZone)
println(localSummerTime) // my result: 2023-08-07T04:00
println(localWinterTime) // my result: 2023-12-07T03:00
}
/**
* Contrary to [m2], [m3]'s results differ only by the month number.
*
* That's because
* the time is stored in [LocalDateTime] not in [Instant]. [Instant] stores the time
* using the number of nanoseconds since the epoch. Whereas [LocalDateTime] only stores
* the numbers you provide in the constructor and they never change regardless of [TimeZone].
*/
fun m3() {
val summerTime = LocalDateTime(2023, 8, 7, 1, 0, 0, 0)
val winterTime = LocalDateTime(2023, 12, 7, 1, 0, 0, 0)
println(summerTime) // 2023-08-07T01:00
println(winterTime) // 2023-12-07T01:00
}
/**
* This method shows how to store the time of past events. Or events that will definitely happen in a
* well-defined instant of time in the future not far away from now (like an order confirmation
* deadline in 1 hour from now).
*
* Store the time of such events in UTC [Instant]. It will be easily converted when a user
* moves to another time zone.
*
* However, you can store it [LocalDateTime] along with the [TimeZone] as is done for
* future events and shown in [m6], but that introduces one more entry and additional hassle.
*/
fun m4() {
// Run this code when the event happens
val storedTime = Clock.System.now()
// When you need to display the value to user
val displayValueInKyiv = storedTime.toLocalDateTime(TimeZone.of("Europe/Kiev"))
val displayValueInBerlin = storedTime.toLocalDateTime(TimeZone.of("Europe/Berlin"))
println(displayValueInKyiv) // my result: 2023-08-07T19:16:53.197945500
println(displayValueInBerlin) // my result: 2023-08-07T18:16:53.197945500
}
/**
* This method explains why storing time in UTC for future events will cause problems when a country
* changes their time zone or decides to introduce/revoke DST.
*
* The correct approach to storing the time of scheduled events is in the [m6].
*
* This is a working example from [Introducing kotlinx-datetime by Ilya Gorbunov](https://youtu.be/YwN0kAMNvXI?t=733).
* The original code (definitely check it to understand the example):
* ```
* val localTz = TimeZone.currentSystemDefault() // Europe/Berlin: UTC+2
* val meetingStarts = LocalDateTime(2025, Month.AUGUST, 23, 13, 00)
* val startInstant = meetingStarts.toInstant(localTz) // 2025-08-13T11:00:00Z
* ... 5 years later ...
* val startInstant = Instant.parse("2025-08-13T11:00:00Z")
* val localTz = TimeZone.currentSystemDefault() // Europe/Berlin: UTC+1
* val meetingStarts = startInstant.toLocalDateTime(localTz) // 2025-08-13T12:00
* ```
*/
fun m5() {
// Let's assume that the DST is absent but the country changes its time zone in the future.
val initialTimeZone = FixedOffsetTimeZone(UtcOffset(hours = 2))
val changedTimeZone = FixedOffsetTimeZone(UtcOffset(hours = 1))
// The event should start at 13:00, so we store the following UTC value
val storedValue = Instant.parse("2025-08-13T11:00:00Z")
val expectedValue = storedValue.toLocalDateTime(initialTimeZone)
val realValue = storedValue.toLocalDateTime(changedTimeZone)
println(expectedValue) // 2025-08-13T13:00
println(realValue) // 2025-08-13T12:00
/*
The real and the expected values are different. The problem is not with the outdated
timezone. The time zone does get updated. But the problem is that the storedValue is a UTC
Instant that only stores the number of nanos since the epoch. This number doesn't change when
a TimeZone changes.
The confusion here is that if we used LocalDateTime instead, it wouldn't change either,
but the program would work properly. That's because the LocalDateTime stores the "clock time",
that's what should be the source of truth.
*/
}
/**
* Currently the latest version of kotlinx-datetime is 0.4.0 and
* [it doesn't have ZonedDateTime out of the box](https://github.com/Kotlin/kotlinx-datetime/issues/163).
* So it's more convenient to define our custom solution for now.
*
* This is an example of how to properly store events scheduled for future.
* [timeZone] should normally be private and immutable, cause it's value gets updated automatically
* when some country changes their TimeZone. But the example in the [m6] is imaginary and the Berlin's
* TimeZone doesn't change in reality, we change it manually instead.
*/
class MyZonedDateTime(
private val localTime: LocalDateTime,
var timeZone: TimeZone,
) {
/**
* Technically, you could just return [localTime] in this method. But we make this conversion
* cause when the user moves to a different TimeZone, they expect a different time to be displayed.
*/
fun displayTime(newTimeZone: TimeZone): String {
val initialTime = localTime.toInstant(timeZone)
val resultingLocalTime = initialTime.toLocalDateTime(newTimeZone)
return resultingLocalTime.toString()
}
}
/**
* Here is the corrected solution from [m5]
*
* So when Berlin changes their TimeZone, the event time shouldn't change. It will still happen
* at the arranged time.
* However, the physical time of the event will differ from the arranged one. That's why other
* countries will observe the change.
*/
fun m6() {
// define constants (just for the example)
val kyivTimeZone = TimeZone.of("Europe/Kiev")
val berlinTimeZoneCurrent = TimeZone.of("UTC+2")
val berlinTimeZoneChanged = TimeZone.of("UTC+1")
val eventTime = LocalDateTime(2025, 8, 13, 13,0,0)
val zonedDateTime = MyZonedDateTime(eventTime, berlinTimeZoneCurrent)
println("Event time showed in Berlin: ${zonedDateTime.displayTime(berlinTimeZoneCurrent)}")
println("Event time showed in Kyiv: ${zonedDateTime.displayTime(kyivTimeZone)}")
println()
println("Berlin's TimeZone changes")
println()
zonedDateTime.timeZone = berlinTimeZoneChanged
println("Event time showed in Berlin: ${zonedDateTime.displayTime(berlinTimeZoneChanged)}")
println("Event time showed in Kyiv: ${zonedDateTime.displayTime(kyivTimeZone)}")
}

Instant vs LocalDateTime

Instant stores the number of nanoseconds since the epoch, whereas LocalDateTime stores the data in the fields: year, month, dayOfMonth, hour, minute, etc. You'll notice the difference in the following two situations:

  1. If the time zone changes, Instant will return the same value, whereas LocalDateTime will return a different one. The data stored in both will not change though.
  2. Imagine the clocks get set forward/backward due to DST. LocalDateTime will refer to the same time since it represents "clock time", which is logical as we don't change the time of events in our schedule. However, Instant will then refer to a different time, since it represents the physical time. Thus, Instant can not be the source of truth for future events.

Why not use LocalDateTime everywhere?

We are required to store the time of future events in LocalDateTime. Why not store past events in LocalDateTime as well then? You can do that, but this approach introduces additional hassle and wastes some resources. In addition, it's more convenient to work with Instants.

Why do we not get issues when past events are stored in UTC (Instants)?

As I got it, that's because when a country updates its time zone, the time zone database gets a new set of rules. Your stored time for past events will not change, unlike the time of future ones.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment