Skip to content
95 changes: 59 additions & 36 deletions base/src/main/kotlin/klib/base/DateTimeKit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@ object DateTimeKit {

}

import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
import klib.base.ShortString.asShortString
import klib.base.ShortString.shortStringAsLong

import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
import java.time.Duration
import java.time.Instant
import kotlin.math.pow

private val dateTimeFormatterUTC = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC)
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter

@Suppress("MagicNumber")
object ShortString {
Expand All @@ -98,67 +98,90 @@ object ShortString {

@Suppress("MagicNumber")
@JvmInline
value class InstantWithDuration(private val packedValue: Long) {
value class InstantWithDuration(internal val packedValue: Long) : Comparable<InstantWithDuration> {

init {
require(packedValue >= 0) { "PackedValue must be non-negative" }
require(packedValue shr (63 - BITS_FOR_DURATION) == 0L) { "StartEpochSeconds out of range" }
require(durationMinutes <= MAX_DURATION_MINUTES) { "Duration must be at most $MAX_DURATION_MINUTES" }
require((packedValue shr BITS_FOR_DURATION) <= INSTANT_SECONDS_MASK) {
"StartEpochSeconds out of range (${(packedValue shr BITS_FOR_DURATION) + EPOCH_2020})"
}
require(durationMinutes <= MAX_DURATION_MINUTES) {
"Max duration minutes exceeded by ${durationMinutes - MAX_DURATION_MINUTES}"
}
}

val startEpochSeconds: Long
get() = EPOCH_2020 + (packedValue shr BITS_FOR_DURATION)

val startInstant
get() = Instant.ofEpochSecond(startEpochSeconds)

val durationMinutes: UShort
get() = (packedValue and DURATION_MASK).toUShort()

val duration
get() = Duration.ofMinutes(durationMinutes.toLong())

val endEpochSeconds: Long
get() = startEpochSeconds + durationMinutes.toLong() * 60

val endInstant
get() = Instant.ofEpochSecond(endEpochSeconds)
val durationMinutes: UInt
get() = (packedValue and DURATION_MINUTES_MASK).toUInt()

val startFormatted get() = dateTimeFormatterUTC.format(startInstant)
val endFormatted get() = dateTimeFormatterUTC.format(endInstant)
@get:JsonValue
val asShortString get() = packedValue.asShortString

override fun toString() = "${durationMinutes}m @ $startFormatted"

@get:JsonValue
val asShortString get() = packedValue.asShortString
override fun compareTo(other: InstantWithDuration): Int = packedValue.compareTo(other.packedValue)

companion object {
const val EPOCH_2020 = 1577836800L
const val BITS_FOR_DURATION = 11
val MAX_DURATION_MINUTES: UShort get() = (2.0.pow(BITS_FOR_DURATION).toUInt() - 1u).toUShort()
const val DURATION_MASK = (1L shl BITS_FOR_DURATION) - 1
const val MAX_SECONDS = (1L shl (63 - BITS_FOR_DURATION)) - 1
const val BITS_FOR_DURATION = 26 // 127.7 years or 67_108_863 minutes
const val DURATION_MINUTES_MASK: Long = (1L shl BITS_FOR_DURATION) - 1
val MAX_DURATION_MINUTES: UInt = DURATION_MINUTES_MASK.toUInt()
const val INSTANT_SECONDS_MASK = (1L shl (63 - BITS_FOR_DURATION)) - 1 // 4358 future years

@JsonCreator
@JvmStatic
fun fromShortString(value: String) = InstantWithDuration(value.shortStringAsLong)

fun fromStartAndEnd(start: String, end: String) =
fromStartAndEnd(Instant.parse(start), Instant.parse(end))

fun fromStartAndEnd(start: Instant, end: Instant): InstantWithDuration {
require(end >= start) { "End time must not be before start time" }
val durationMinutes = Duration.between(start, end).toMinutes().toUShort()
val durationMinutes = Duration.between(start, end).toMinutes().toUInt()
return fromStartAndDuration(start, durationMinutes)
}

fun fromStartAndDuration(start: String, durationMinutes: UShort = 0u) =
fun fromStartAndDuration(start: String, durationMinutes: UInt = 0u) =
fromStartAndDuration(Instant.parse(start), durationMinutes)

fun fromStartAndDuration(start: Instant, durationMinutes: UShort = 0u) =
fun fromStartAndDuration(start: Instant, durationMinutes: UInt = 0u) =
fromStartAndDuration(start.epochSecond, durationMinutes)

fun fromStartAndDuration(startEpochSeconds: Long, durationMinutes: UShort = 0u): InstantWithDuration {
fun fromStartAndDuration(startEpochSeconds: Long, durationMinutes: UInt = 0u): InstantWithDuration {
require(durationMinutes <= MAX_DURATION_MINUTES) {
"Max duration minutes exceeded by ${durationMinutes - MAX_DURATION_MINUTES}"
}
require(startEpochSeconds >= EPOCH_2020 - INSTANT_SECONDS_MASK + 1) {
"Min startEpochSeconds should be increased by ${
EPOCH_2020 - INSTANT_SECONDS_MASK + 1 - startEpochSeconds
}"
}
require(startEpochSeconds <= EPOCH_2020 + INSTANT_SECONDS_MASK) {
"Max startEpochSeconds exceeded by ${startEpochSeconds - (EPOCH_2020 + INSTANT_SECONDS_MASK)}"
}
return InstantWithDuration(
((startEpochSeconds - EPOCH_2020).toInt().toLong() shl BITS_FOR_DURATION) or durationMinutes.toLong()
((startEpochSeconds - EPOCH_2020) shl BITS_FOR_DURATION) or durationMinutes.toLong()
)
}
}
}

val InstantWithDuration.startInstant
get() = Instant.ofEpochSecond(startEpochSeconds)

val InstantWithDuration.duration
get() = Duration.ofMinutes(durationMinutes.toLong())

@Suppress("MagicNumber")
val InstantWithDuration.endEpochSeconds: Long
get() = startEpochSeconds + durationMinutes.toLong() * 60

val InstantWithDuration.endInstant
get() = Instant.ofEpochSecond(endEpochSeconds)

private val dateTimeFormatterUTC = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)

val InstantWithDuration.startFormatted get() = dateTimeFormatterUTC.format(startInstant)
val InstantWithDuration.endFormatted get() = dateTimeFormatterUTC.format(endInstant)
197 changes: 197 additions & 0 deletions base/src/test/kotlin/klib/base/InstantWithDurationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package klib.base

import klib.base.InstantWithDuration.Companion.DURATION_MINUTES_MASK
import klib.base.InstantWithDuration.Companion.EPOCH_2020
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import kotlin.test.assertEquals

@DisplayName("InstantWithDuration")
class InstantWithDurationTest {

@Test
fun `Constructor with startEpochSeconds and durationMinutes creates correct packedValue`() {
val instantWithDuration = InstantWithDuration
.fromStartAndDuration(EPOCH_2020, MAX_DURATION_MINUTES)
assertEquals("2020-01-01T00:00:00Z", instantWithDuration.startFormatted, "startFormatted")
assertEquals("2147-08-06T09:03:00Z", instantWithDuration.endFormatted, "endFormatted")
assertEquals(
60 * DURATION_MINUTES_MASK,
instantWithDuration.endEpochSeconds - instantWithDuration.startEpochSeconds,
"Max duration"
)
assertEquals(DURATION_MINUTES_MASK, instantWithDuration.packedValue and DURATION_MINUTES_MASK, "packedValue")
assertEquals(0x3ffffff, instantWithDuration.packedValue, "packedValue should be 67108863")
}

@Test
fun `Constructor with packedValue creates correct startEpochSeconds and durationMinutes`() {
val instantWithDuration = InstantWithDuration(0x7ffL or DURATION_MINUTES_MASK)
assertEquals(EPOCH_2020, instantWithDuration.startEpochSeconds)
assertEquals(MAX_DURATION_MINUTES, instantWithDuration.durationMinutes)
}

@ParameterizedTest
@CsvSource(
"2019-12-31T23:58:00Z, 2019-12-31T23:59:00Z, 1",
"2020-01-01T00:00:00Z, 2020-01-01T00:00:00Z, 0",
"2025-08-01T10:00:00Z, 2025-08-01T10:05:00Z, 5",
"2025-08-01T10:00:00Z, 2025-08-01T10:30:00Z, 30",
"2025-08-01T10:00:00Z, 2025-08-01T11:00:00Z, 60",
"6375-04-08T15:04:31Z, 6502-11-12T00:07:31Z, $DURATION_MINUTES_MASK"
)
@DisplayName("Factory methods")
fun testFromStartFactory(start: String, end: String, expectedDurationMinutes: UInt) {
InstantWithDuration.fromStartAndEnd(start, end).run {
assertEquals(start, startFormatted)
assertEquals(end, endFormatted)
assertEquals(expectedDurationMinutes, durationMinutes, "fromStartAndEnd")
}
InstantWithDuration.fromStartAndDuration(start, expectedDurationMinutes).run {
assertEquals(start, startFormatted)
assertEquals(end, endFormatted)
assertEquals(expectedDurationMinutes, durationMinutes, "fromStartAndDuration")
}
}

@Test
@DisplayName("'fromStartAndEnd' throws IllegalArgumentException when end is before start")
fun testFromStartAndEndThrowsException() {
val start = "2025-08-01T09:00:01Z"
val end = "2025-08-01T09:00:00Z"

assertThrows<IllegalArgumentException> {
InstantWithDuration.fromStartAndEnd(start, end)
}
}

@ParameterizedTest
@CsvSource(
"${EPOCH_2020 - 1}, 0, -13ydj4",
"$EPOCH_2020, 0, 000000",
"$EPOCH_2020, 1, 000001",
"$EPOCH_2020, $DURATION_MINUTES_MASK, 13ydj3",
"${EPOCH_2020 + 60}, 0, 1ulajuo",
"${EPOCH_2020 + INSTANT_SECONDS_MASK}, $DURATION_MINUTES_MASK, 1y2p0ij32e8e7",
)
@DisplayName("Roundtrip (asShortString -> fromShortString)")
fun roundTrip(
startEpochSeconds: Long,
durationMinutes: UInt,
expectedShortString: String
) {
val encodedString = InstantWithDuration.fromStartAndDuration(startEpochSeconds, durationMinutes).asShortString
InstantWithDuration.fromShortString(encodedString).also { decoded ->
assertEquals(startEpochSeconds, decoded.startEpochSeconds)
assertEquals(durationMinutes, decoded.durationMinutes)
}
assertEquals(expectedShortString, encodedString)
}

@Test
fun `'endEpochSeconds' for EPOCH_2020`() {
val instantWithDuration = InstantWithDuration.fromStartAndDuration(EPOCH_2020)
assertEquals(
EPOCH_2020,
instantWithDuration.endEpochSeconds,
"endEpochSeconds"
)
}

@Test
fun `'endEpochSeconds' for EPOCH_2020 minus 2 minutes`() {
val instantWithDuration = InstantWithDuration.fromStartAndDuration(
EPOCH_2020 - 120, 1u
)
assertEquals(
EPOCH_2020 - 60,
instantWithDuration.endEpochSeconds,
"endEpochSeconds"
)
}

@Test
fun `Maximum representable values`() {
val instantWithDuration = InstantWithDuration.fromStartAndDuration(
EPOCH_2020 + INSTANT_SECONDS_MASK, MAX_DURATION_MINUTES
)
assertEquals("6375-04-08T15:04:31Z", instantWithDuration.startFormatted, "startFormatted")
assertEquals("6502-11-12T00:07:31Z", instantWithDuration.endFormatted, "endFormatted")
assertEquals(EPOCH_2020 + INSTANT_SECONDS_MASK, instantWithDuration.startEpochSeconds, "startEpochSeconds")
assertEquals(143043322051, instantWithDuration.endEpochSeconds, "endEpochSeconds")
assertEquals(DURATION_MINUTES_MASK.toUInt(), instantWithDuration.durationMinutes, "durationMinutes")
assertEquals(DURATION_MINUTES_MASK, instantWithDuration.duration.toMinutes(), "duration")
}

@Test
fun `Constructor throws IllegalArgumentException for values exceeding maximum`() {
assertThrows<IllegalArgumentException>("Start") {
InstantWithDuration.fromStartAndDuration(
EPOCH_2020 + INSTANT_SECONDS_MASK + 1,
0u
)
}

assertThrows<IllegalArgumentException>("Duration") {
InstantWithDuration.fromStartAndDuration(
EPOCH_2020,
MAX_DURATION_MINUTES + 1u
)
}
}

@ParameterizedTest
@CsvSource(
"$EPOCH_2020, 0, '0m @ 2020-01-01T00:00:00Z'",
"$EPOCH_2020, 1, '1m @ 2020-01-01T00:00:00Z'",
"$EPOCH_2020, 2047, '2047m @ 2020-01-01T00:00:00Z'",
"$EPOCH_2020, $DURATION_MINUTES_MASK, '67108863m @ 2020-01-01T00:00:00Z'",
"${EPOCH_2020 + 60}, 0, '0m @ 2020-01-01T00:01:00Z'",
)
@DisplayName("'toShortString()' for various inputs")
fun `toShortString()`(
startEpochSeconds: Long,
durationMinutes: UInt,
expected: String
) {
assertEquals(
expected,
InstantWithDuration.fromStartAndDuration(startEpochSeconds, durationMinutes).toString()
)
}

@ParameterizedTest
@CsvSource(
"${EPOCH_2020 - 1}, 0, '2019-12-31T23:59:59Z'",
"$EPOCH_2020, 1, '2020-01-01T00:01:00Z'",
"${EPOCH_2020 + 60}, 0, '2020-01-01T00:01:00Z'",
"$EPOCH_2020, 2047, '2020-01-02T10:07:00Z'",
"$EPOCH_2020, $DURATION_MINUTES_MASK, '2147-08-06T09:03:00Z'",
"${EPOCH_2020 + DURATION_MINUTES_MASK * 60}, 0, '2147-08-06T09:03:00Z'",
)
@DisplayName("'endFormatted' for various inputs")
fun endFormatted(
startEpochSeconds: Long,
durationMinutes: UInt,
expectedEnd: String
) {
assertEquals(
expectedEnd,
InstantWithDuration.fromStartAndDuration(startEpochSeconds, durationMinutes).endFormatted
)
}

@Test
fun `'compareTo' works correctly`() {
val earlier = InstantWithDuration.fromStartAndDuration(EPOCH_2020, 30u)
val later = InstantWithDuration.fromStartAndDuration(EPOCH_2020, 60u)
val sameEndAsLater = InstantWithDuration.fromStartAndDuration(EPOCH_2020 + 1800, 30u)

assertEquals(later.endFormatted, sameEndAsLater.endFormatted)
assert(earlier < later)
assert(later > earlier)
assert(earlier < sameEndAsLater)
assert(later < sameEndAsLater)
}
}