Ranges & Time Slots

Time intervals at every level — from calendar date ranges to intra-day time slots — with containment checks, intersection tests, and set operations.

All range types (except MpLocalTimeSlot) have a Partial variant for when only one boundary is known. This is common in search filters, open-ended subscriptions, and "from now on" queries. Convert to a concrete range via asValidRange(), which fills in Genesis or Doomsday for the missing boundary.

MpLocalDateRange

An open-ended date range [from, to) — the end date is excluded. This is the natural model for hotel stays, billing periods, and date iteration:

import io.peekandpoke.ultra.datetime.MpLocalDateRange
import io.peekandpoke.ultra.datetime.MpLocalDate

val range = MpLocalDateRange(
    from = MpLocalDate.of(2025, 3, 10),
    to = MpLocalDate.of(2025, 3, 15),
)

println(range.numberOfDays)    // 5
println(range.numberOfNights)  // 5

// Iterate over all dates in the range
val dates = range.asListOfDates()
// [2025-03-10, 2025-03-11, 2025-03-12, 2025-03-13, 2025-03-14]

// Properties
range.asDatePeriod     // MpDatePeriod(0y, 0m, 5d)
range.asClosedRange    // MpClosedLocalDateRange [2025-03-10, 2025-03-14]
range.hasStart         // true (from is after Genesis)
range.hasEnd           // true (to is before Doomsday)
range.isOpen           // false (both boundaries set)
range.isValid          // true (from < to)

// Factory methods
val forever = MpLocalDateRange.forever        // Genesis to Doomsday
val from = MpLocalDateRange.beginningAt(date) // date to Doomsday
val until = MpLocalDateRange.endingAt(date)   // Genesis to date

// Convert to timezone-aware range
val zoned = range.toZonedTimeRange(MpTimezone.of("Europe/Berlin"))
    .fromNoonToNoon  // MpZonedDateTimeRange

// Convert to Partial
val partial = range.asPartialRange()  // Partial(from, to)
Open-ended ranges are the right default for most business logic. A 3-night hotel stay from March 10 to March 13 has from=10, to=13 — you check out on the 13th, not stay through it.

Set operations

val a = MpLocalDateRange(
    from = MpLocalDate.parse("2024-01-01"),
    to = MpLocalDate.parse("2024-06-30"),
)
val b = MpLocalDateRange(
    from = MpLocalDate.parse("2024-03-01"),
    to = MpLocalDate.parse("2024-09-30"),
)

// Containment
a.contains(MpLocalDate.parse("2024-03-15"))  // true
a.contains(b)                                 // false (b extends past a)

// Intersection and touching
a.intersects(b)                               // true
a.touches(b)                                  // true
a.isAdjacentTo(b)                             // false (they overlap)

// Merge overlapping ranges
a.mergeWith(b)  // [MpLocalDateRange(2024-01-01, 2024-09-30)]

// Cut — subtract one range from another
a.cutAway(b)    // [MpLocalDateRange(2024-01-01, 2024-03-01)]

// Also works with closed ranges
a.intersects(closedRange)

MpLocalDateRange.Partial

// Only a start — open-ended into the future
val fromMarch = MpLocalDateRange.Partial(
    from = MpLocalDate.of(2025, 3, 1),
    to = null,
)

// Only an end — open-ended into the past
val untilJune = MpLocalDateRange.Partial(
    from = null,
    to = MpLocalDate.of(2025, 6, 1),
)

// Both null — represents "any time"
val anytime = MpLocalDateRange.Partial(from = null, to = null)

// Convert to a concrete range (fills in Genesis/Doomsday)
val concrete = fromMarch.asValidRange()
// MpLocalDateRange(2025-03-01, +10000-01-01)

MpClosedLocalDateRange

A closed date range [from, to] — the end date is included:

import io.peekandpoke.ultra.datetime.MpClosedLocalDateRange

val closed = MpClosedLocalDateRange(
    from = MpLocalDate.of(2025, 3, 10),
    to = MpLocalDate.of(2025, 3, 14),
)

println(closed.numberOfDays)    // 5 (includes both endpoints)
println(closed.numberOfNights)  // 4

// Properties
closed.asOpenRange      // MpLocalDateRange [2025-03-10, 2025-03-15)
closed.asDatePeriod     // MpDatePeriod(0y, 0m, 5d)
closed.hasStart         // true
closed.hasEnd           // true
closed.isOpen           // false
closed.isValid          // true

// Factory methods
val forever = MpClosedLocalDateRange.forever
val from = MpClosedLocalDateRange.beginningAt(date) // date to Doomsday
val until = MpClosedLocalDateRange.endingAt(date)   // Genesis to date

// Convert to timezone-aware range
val zoned = closed.toZonedTimeRange(MpTimezone.of("Europe/Berlin"))

// Iterate dates
closed.asListOfDates()  // [2025-03-10, ..., 2025-03-14]

// Set operations (same as MpLocalDateRange)
closed.contains(date)
closed.contains(otherClosed)
closed.intersects(otherClosed)
closed.intersects(openRange)   // works with open ranges too
closed.touches(otherClosed)
closed.isAdjacentTo(otherClosed)
closed.mergeWith(otherClosed)
closed.cutAway(otherClosed)

MpClosedLocalDateRange.Partial

val partial = MpClosedLocalDateRange.Partial(
    from = MpLocalDate.of(2025, 3, 10),
    to = null,
)

val concrete = partial.asValidRange()
// MpClosedLocalDateRange(2025-03-10, +10000-01-01)

MpInstantRange

A range of absolute timestamps. Supports containment checks, intersection tests, set subtraction, and timezone-aware arithmetic:

import io.peekandpoke.ultra.datetime.MpInstantRange
import io.peekandpoke.ultra.datetime.MpInstant

val start = MpInstant.parse("2025-03-15T08:00:00Z")
val end = MpInstant.parse("2025-03-15T17:00:00Z")

val workday = MpInstantRange(from = start, to = end)

// Properties
workday.duration    // 9 hours (kotlin.time.Duration)
workday.hasStart    // true (from is after Genesis)
workday.hasEnd      // true (to is before Doomsday)
workday.isOpen      // false
workday.isValid     // true (from < to)

// Factory methods
val fromDuration = MpInstantRange.of(start, 2.hours)  // start + 2h
val forever = MpInstantRange.forever
val from = MpInstantRange.beginningAt(start)           // start to Doomsday
val until = MpInstantRange.endingAt(end)               // Genesis to end

// Convert to zoned
val zoned = workday.atZone(MpTimezone.of("Europe/Berlin"))
val utc = workday.atZone(MpTimezone.UTC)
val local = workday.atSystemDefaultZone()

// Convert to Partial
val partial = workday.asPartialRange()

Set operations

// Containment — is a specific instant inside this range?
val noon = MpInstant.parse("2025-03-15T12:00:00Z")
workday.contains(noon)   // true

// Also works with MpZonedDateTime and other MpAbsoluteDateTime types
workday.contains(zonedDateTime)  // true/false

// Range containment
workday.contains(otherRange)     // true if other is fully inside

// Intersection
val afternoon = MpInstantRange(
    from = MpInstant.parse("2025-03-15T13:00:00Z"),
    to = MpInstant.parse("2025-03-15T20:00:00Z"),
)
workday.intersects(afternoon)    // true
workday.touches(afternoon)       // true
workday.isAdjacentTo(afternoon)  // false (they overlap)

// Merge
workday.mergeWith(afternoon)     // one merged range

// Cut — subtract one range from another
workday.cutAway(afternoon)       // [08:00-13:00]

Arithmetic

import kotlin.time.Duration.Companion.hours
import kotlinx.datetime.DateTimeUnit

// Shift by absolute Duration
val shifted = workday.plus(2.hours)
val earlier = workday.minus(1.hours)

// Shift by calendar unit (DST-aware, requires timezone)
val tomorrow = workday.plus(1, DateTimeUnit.DAY, MpTimezone.of("Europe/Berlin").kotlinx)
val lastWeek = workday.minus(7, DateTimeUnit.DAY, MpTimezone.of("Europe/Berlin").kotlinx)

MpInstantRange.Partial

val partial = MpInstantRange.Partial(
    from = MpInstant.parse("2025-03-15T08:00:00Z"),
    to = null,  // open-ended
)

val concrete = partial.asValidRange()
// MpInstantRange(2025-03-15T08:00:00Z, +10000-01-01T00:00:00Z)

// Convert partial to a date range in a specific timezone
val dateRange = partial.asDateRange(MpTimezone.of("Europe/Berlin"))
// MpClosedLocalDateRange.Partial

MpZonedDateTimeRange

A timezone-aware datetime range. Knows its duration, supports set operations, and can be shifted with DST-aware arithmetic:

import io.peekandpoke.ultra.datetime.MpZonedDateTimeRange
import io.peekandpoke.ultra.datetime.MpTimezone

val berlin = MpTimezone.of("Europe/Berlin")

val range = MpZonedDateTimeRange(
    from = MpInstant.parse("2025-03-15T08:00:00Z").atZone(berlin),
    to = MpInstant.parse("2025-03-15T17:00:00Z").atZone(berlin),
)

// Properties
range.duration      // 9 hours
range.hasStart      // true
range.hasEnd        // true
range.isOpen        // false — both boundaries present
range.isValid       // true — from is before to

// Factory methods
val fromDuration = MpZonedDateTimeRange.of(zonedStart, 2.hours)
val fromPeriod = MpZonedDateTimeRange.of(zonedStart, MpDateTimePeriod.of(months = 1))
val forever = MpZonedDateTimeRange.forever
val from = MpZonedDateTimeRange.beginningAt(zonedStart)
val until = MpZonedDateTimeRange.endingAt(zonedEnd)

// Conversions
range.asDateRange()         // MpClosedLocalDateRange
range.toInstantRange()      // MpInstantRange (drops timezone)
range.atZone(otherTimezone) // re-zone to different timezone
range.asPartialRange()      // Partial(from, to)

Set operations

// Containment
range.contains(zonedDateTime)                // MpAbsoluteDateTime
range.contains(localDateTime, timezone)      // MpLocalDateTime + timezone
range.contains(otherRange)                   // full range containment

// Intersection and touching
range.intersects(otherRange)
range.touches(otherRange)
range.isAdjacentTo(otherRange)

// Merge and cut
range.mergeWith(otherRange)
range.cutAway(otherRange)

Arithmetic

// Shift by absolute Duration
val shifted = range.plus(2.hours)
val earlier = range.minus(1.hours)

// Shift by calendar unit (DST-aware — uses the range's own timezone)
val tomorrow = range.plus(1, DateTimeUnit.DAY)
val nextMonth = range.plus(1, DateTimeUnit.MONTH)
val lastWeek = range.minus(7, DateTimeUnit.DAY)

MpZonedDateTimeRange.Partial

val partial = MpZonedDateTimeRange.Partial(
    from = MpInstant.parse("2025-03-15T08:00:00Z").atZone(berlin),
    to = null,  // open-ended
)

val concrete = partial.asValidRange()

// Convert to a date range partial
val datePartial = partial.asDateRange()  // MpClosedLocalDateRange.Partial

MpLocalTimeSlot

A time range within a single day. Purpose-built for scheduling, availability calendars, and business hours:

import io.peekandpoke.ultra.datetime.MpLocalTimeSlot
import io.peekandpoke.ultra.datetime.MpLocalTime

val morning = MpLocalTimeSlot(
    from = MpLocalTime.of(9, 0),
    to = MpLocalTime.of(12, 0),
)

// Properties
morning.duration    // 3 hours
morning.isValid     // true (duration > 0)

// Factory methods
val fromDuration = MpLocalTimeSlot.of(MpLocalTime.of(9, 0), 3.hours)
val fromSeconds = MpLocalTimeSlot.ofSecondsOfDay(32400, 43200)  // 9:00-12:00
val wholeDay = MpLocalTimeSlot.completeDay  // 00:00 to 23:59:59.999

// Formatting
morning.formatHhMm()    // "09:00 - 12:00"
morning.formatHhMmSs()  // "09:00:00 - 12:00:00"

Set operations

val afternoon = MpLocalTimeSlot(
    from = MpLocalTime.of(13, 0),
    to = MpLocalTime.of(17, 0),
)

// Containment
morning.contains(MpLocalTime.of(10, 30))  // true
morning.contains(afternoon)                // false

// Intersection and touching
morning.intersects(afternoon)              // false (gap between them)
morning.touches(afternoon)                 // false (12:00 to 13:00 gap)

val extended = MpLocalTimeSlot(
    from = MpLocalTime.of(11, 0),
    to = MpLocalTime.of(14, 0),
)
morning.intersects(extended)               // true
morning.touches(extended)                  // true
morning.isAdjacentTo(extended)             // false (they overlap)

// Merge overlapping slots
morning.mergeWith(extended)
// [MpLocalTimeSlot(09:00, 14:00)]

// Cut — remove one slot from another
afternoon.cutAway(extended)
// [MpLocalTimeSlot(14:00, 17:00)]

// Split into fixed-duration slots with gaps
morning.splitWithGaps(duration = 1.hours, gap = 15.minutes)
// [09:00-10:00, 10:15-11:15]

Range operations summary

Operation DateRange ClosedDateRange InstantRange ZonedRange TimeSlot
contains(point) yes yes yes yes yes
contains(range) yes yes yes yes yes
intersects() yes yes yes yes yes
touches() yes yes yes yes yes
isAdjacentTo() yes yes yes yes yes
mergeWith() yes yes yes yes yes
cutAway() yes yes yes yes yes
splitWithGaps() - - - - yes
asListOfDates() yes yes - - -
duration - - yes yes yes
plus() / minus() - - yes yes -
Partial variant yes yes yes yes -