Pantrix UI

Date and time

Why a day (year, month, day) and a time of day are not found by themselves in common code in Compose Multiplatform, what the library gives for it, how a day and a time are turned into an app's own type, how a day and a time become a moment, how they are written by language (12 or 24 hours included), how "today" is known, and how a range that slides with the clock ("the last 15 minutes") is kept and read. The problem is not Pantrix's own: it is seen the same way in every Compose Multiplatform project that shows a date or has one chosen.

Everything said here was seen in the standard library of Kotlin 2.4.21 and in tests that run in this repository; which was seen where is written in section 8.

1. The problem

Symptom

Cause

Kotlin 2.4.21's standard library gives Instant, Clock and Duration in the kotlin.time package; they are not experimental, and ask for no opt-in. A calendar day, time zones and writing a day are not in the standard library:

WhatWhere it is found
A moment, nowThe standard library: kotlin.time.Instant, Clock.System.now()
A calendar day, time zoneskotlinx-datetime (a separate dependency), java.time (JVM, Android), NSCalendar (iOS), Date (web)
The device clock's offset from UTCEach platform's own API: java.util.TimeZone, NSTimeZone, Date.getTimezoneOffset()
Month and day namesEach platform's localisation; not in common code

2. The solution: nine pieces

In foundation (com.pantrixui.foundation.date and com.pantrixui.foundation.l10n):

  1. PantrixDate(year, month, day): a day with no time and no time zone. It is validated when built (a 13th month, 30 February are refused), can be ordered, and is written and read in ISO 8601 form (2026-10-17, PantrixDate.parse). Its arithmetic: dayOfWeek (ISO: 1 Monday, 7 Sunday), epochDays and ofEpochDays (the number of days from 1970), plusDays, plusMonths and plusYears (the month's last day where the month is short), daysInMonth, isLeapYear.

  2. PantrixDateRange(start, end): a range of days with both ends included; its end cannot be before its start. day in range.

  3. Today and the screen's clock: PantrixDate.today() is the day the device's clock shows; PantrixDate.of(moment, offset) is which day a moment is on a clock offset ahead of UTC. On a screen, rememberToday(): the day of the screen's clock, renewed by itself at midnight. The screen's clock is PantrixClock(clock, utcOffset), a Clock and an offset function; LocalPantrixClock gives it, and its default is the device's (PantrixClock.System). rememberToday(), the time field's picker, the elastic date picker and the charts' moments read it; a test or a preview gives its own clock (3.4, 6).

  4. systemUtcOffset(moment) (com.pantrixui.foundation.platform): how far the device's clock is ahead of UTC at that moment, summer time included. With each platform's own API.

  5. The date words of PantrixStrings, in each language: short and long month names (month, longMonth), the days of the week (weekday, shortWeekday), a day (date: "Oct 17, 2026"), a full day with the day of the week (longDate: "Saturday, October 17, 2026"), a month (monthYear, longMonthYear: "October 2026").

  6. PantrixTime(hour, minute, second = 0): a 24-hour time with no day and no time zone. It is validated when built, can be ordered, and is written and read in ISO 8601 form (14:05, 14:05:30, PantrixTime.parse); secondOfDay and ofSecondOfDay, PantrixTime.of(moment, offset), PantrixTime.now(). With PantrixDate.at(time), a day and a time become a moment (Instant) on the device's clock, the nights the clock is put forward and back included (3.7).

  7. The time words of PantrixStrings: time(hour, minute, is24Hour = …) writes a time in the layout the caller asks for: true 24 hours (22:00, like clock), false 12 hours with the language's words for the half of the day ("10:00 PM", "ÖS 10:00", "٢:٠٥ م"). The pack does not know which layout to use; the caller says. hour, minute, period, am, pm and twelveHourTemplate are a time picker's words.

  8. PantrixTimePoint and PantrixTimeRange: a point in time that can slide with the clock, and two ends. A point is now (Now), a span from now (Relative(-15, Minutes), which can be rounded to a unit: Relative(-1, Days, roundTo = Days) is yesterday) or a moment (Absolute(instant)). resolve(now) turns the points into the two Instants of that moment: the start is rounded down, the end up; a range whose end falls before its start is null. The units are PantrixTimeUnit: from the second to the hour, elapsed time; from the day to the year, the calendar's (the summer-time night included). The ends are written and read in Elasticsearch's and Kibana's date math: toDateMath() (now-15m, now/d, 2026-10-17T14:15:00+03:00), PantrixTimePoint.parseDateMath.

  9. PantrixTimeWords (PantrixStrings.timeWords): the words of a span, with each language's plural rules: "~ 15 minutes ago", "Last 15 minutes", "Next 2 hours", ", rounded to the day". English (one and many), Turkish (a single form) and Arabic (CLDR's six forms, Arabic digits).

In the components, on top of these stand PantrixDatePicker (date.datepicker), a calendar that has a day or a range chosen; PantrixTimePicker (date.timepicker), a picker that has a time chosen from columns; PantrixElasticDatePicker (date.elasticdatepicker), a field like Elastic UI's EuiSuperDatePicker that has a range chosen that slides with the clock: the quick menu, the range's name or its two ends, Refresh and Update; PantrixDateTextField and PantrixDateFilledTextField (date.datetextfield, date.datefilledtextfield), text fields that have a day typed in digits or chosen from the calendar their button opens (3.10); and PantrixTimeTextField and PantrixTimeFilledTextField (date.timetextfield, date.timefilledtextfield), fields that have a time typed the same way or chosen from the time picker (3.11). The four fields share one inner core (date/common/PickerField.kt); each gives only its own digit rules, errors and picker.

No date dependency was added to the library. PantrixDate and PantrixTime are three numbers each; an app turns them into its own type in one line (3.2, 3.7).

3. With Pantrix

3.1. Having a day chosen

var due by remember { mutableStateOf<PantrixDate?>(null) }
PantrixDatePicker(
    selected = due,
    onSelect = { due = it },
    minDate = rememberToday(),
    tagKey = "due"
)

The calendar does not keep the selection: selected is the app's, and the day pressed comes to onSelect. Opening it in a popover, a dialog or a sheet is the app's job; the demo shows all four, the page included (DatePickerSamples.kt).

The calendar stands at its own width (the room of seven days; on a phone, as much as the platform's touch target), so it does not widen a popover or a dialog needlessly. In a sheet, or at a page's width, give Modifier.fillMaxWidth(): the calendar fills that width, its columns widen up to DatePickerStyle.maxCellWidth (one and a half times the cell), and its rows and the days' circles keep their height; in a wider room it stands in the middle.

3.2. Turning it into your own date type

kotlinx-datetime (0.7 and later; in older versions monthNumber in place of month.number, and dayOfMonth in place of day):

fun LocalDate.toPantrix(): PantrixDate = PantrixDate(year, month.number, day)
fun PantrixDate.toLocalDate(): LocalDate = LocalDate(year, month, day)

java.time (JVM, Android):

fun java.time.LocalDate.toPantrix(): PantrixDate = PantrixDate(year, monthValue, dayOfMonth)
fun PantrixDate.toJavaLocalDate(): java.time.LocalDate = java.time.LocalDate.of(year, month, day)

With a server or a JSON, by ISO 8601 text: PantrixDate.parse("2026-10-17") and date.toString().

By a number (a database, preferences): date.epochDays and PantrixDate.ofEpochDays(days).

3.3. Writing a day

val strings = PantrixTheme.strings
strings.date(day.day, day.month, day.year)       // "Oct 17, 2026", "17 Eki 2026", "١٧ أكتوبر ٢٠٢٦"
strings.longDate(day.day, day.month, day.year)   // "Saturday, October 17, 2026", "17 Ekim 2026 Cumartesi"
strings.longMonthYear(day.month, day.year)       // "October 2026", "Ekim 2026"
strings.weekday(day.dayOfWeek)                   // "Saturday", "Cumartesi"

An app's own pack gives these fields in its own language: PantrixStrings(longMonthNames = …, weekdayNames = …). A pack without twelve months and seven days is refused. The day the week starts on is not a word but a habit of the language: it is in PantrixLocale.firstDayOfWeek (Monday in Turkish, Saturday in Arabic, Sunday in English and in the other languages), and a locale can give it itself too: PantrixLocale("de", LayoutDirection.Ltr, firstDayOfWeek = 1).

3.4. Today

3.5. A range

var stay by remember { mutableStateOf<PantrixDateRange?>(null) }
PantrixDatePicker(selectedRange = stay, onRangeSelect = { stay = it }, tagKey = "stay")
val nights = stay?.let { it.end.epochDays - it.start.epochDays }

3.6. Having a time chosen

var at by remember { mutableStateOf<PantrixTime?>(null) }
PantrixTimePicker(
    selected = at,
    onSelect = { at = it },
    minTime = PantrixTime(9, 0),
    maxTime = PantrixTime(17, 45),
    minuteStep = 15,
    is24Hour = true,
    tagKey = "at"
)

The picker does not keep the selection either: pressing a value gives a whole time to onSelect, and the other column stays as it is. is24Hour gives the picker's shape: true two columns (00–23 and the minutes, 22:00), false three columns (12–11, the minutes and AM/PM, 10:00 PM). The default is 24 hours in every language. The time chosen is a 24-hour PantrixTime in both cases; only its showing changes. When writing it on the screen, give the same: strings.time(at.hour, at.minute, is24Hour = …).

If minTime is after maxTime, the times that can be chosen cross midnight (22:00–06:00 is a night). The bounds are read by their hours and minutes, not by their seconds.

3.7. A moment from a day and a time

val moment: Instant = day.at(time)                  // on the device's clock
val back = PantrixDate.of(moment) to PantrixTime.of(moment)
val inIstanbul = day.at(time) { 3.hours }           // on a fixed clock three hours ahead of UTC

A time never shown on the night the clock is put forward (02:30 on the night two turns into three) is ahead by as much as the hour skipped: 03:30. A time shown twice on the night the clock is put back is the first of the two. Both are the moment java.time takes with ZonedDateTime.of.

With kotlinx-datetime and java.time:

fun LocalTime.toPantrix(): PantrixTime = PantrixTime(hour, minute, second)
fun PantrixTime.toLocalTime(): LocalTime = LocalTime(hour, minute, second)
fun java.time.LocalTime.toPantrix(): PantrixTime = PantrixTime(hour, minute, second)
fun PantrixTime.toJavaLocalTime(): java.time.LocalTime = java.time.LocalTime.of(hour, minute, second)

3.8. A range that slides with the clock

val lastQuarterHour = PantrixTimeRange(PantrixTimePoint.Relative(-15, PantrixTimeUnit.Minutes), PantrixTimePoint.Now)
var range by remember { mutableStateOf(lastQuarterHour) }
PantrixElasticDatePicker(
    range = range,
    onRangeChange = { range = it },
    recentlyUsed = rememberRecentRanges(),
    onRefresh = { reload(range) },
    tagKey = "dashboard"
)

// While fetching the data, each time relative to now:
val window: ClosedRange<Instant>? = range.resolve()

The range is a value, not a moment: "the last 15 minutes" is read again at every resolve(). The two Instants that go to a query must be read at every refresh, inside onRefresh; a window read once and kept does not slide.

Auto refresh, the time window and the menu's parts

var refresh by remember { mutableStateOf(PantrixAutoRefresh(every = 30)) }
PantrixElasticDatePicker(
    range = range,
    onRangeChange = { range = it },
    onRefresh = { reload(range) },
    tagKey = "dashboard",
    autoRefresh = refresh,
    onAutoRefreshChange = { refresh = it },
    minAutoRefresh = 5.seconds,
    timeWindowButtons = ElasticTimeWindowButtons()
)

3.9. Date math in an address

val (from, to) = range.toDateMath()                         // "now-15m", "now"
val url = "/logs?from=$from&to=$to"
val back = PantrixTimeRange(
    PantrixTimePoint.parseDateMath(from) ?: PantrixTimePoint.Now,
    PantrixTimePoint.parseDateMath(to) ?: PantrixTimePoint.Now
)

What is read: now, then one operation (now-15m, now+2h; with no number, like Kibana, now-d) and one rounding (now-1d/d, now/w, /1d like Elasticsearch); the units y M w d h m s, and H for the hour. A moment is the form ISO 8601 writes with the UTC offset or with Z; the fraction of a second is kept in groups of three digits (14:15:00.250+03:00). Everything else is null: two operations (now-1d/d+8h), a date with ||, an ISO text with no offset or with only a day.

3.10. Having a day typed: the date field

A day the user knows (a birthday, the start of a contract) is typed rather than looked for in a calendar. PantrixDateTextField is a text field: the day is typed in digits, and the button at the end of the box opens the calendar.

var birthday by remember { mutableStateOf<PantrixDate?>(null) }
PantrixDateTextField(
    value = birthday,
    onValueChange = { birthday = it },
    label = stringResource(Res.string.birthday),
    maxDate = rememberToday(),
    tagKey = "birthday"
)

3.11. Having a time typed: the time field

A time the user knows (the start of a meeting, an alarm) is typed rather than scrolled in columns. PantrixTimeTextField is the date field's counterpart for a time: the time is typed in digits, and the button at the end of the box opens PantrixTimePicker.

var start by remember { mutableStateOf<PantrixTime?>(null) }
PantrixTimeTextField(
    value = start,
    onValueChange = { start = it },
    label = stringResource(Res.string.meeting_start),
    minuteStep = 15,
    tagKey = "start"
)

4. Without Pantrix

The smallest pieces for a project without the library. The day arithmetic is Howard Hinnant's count (the one the standard libraries of C++ and Rust use), exact for every Gregorian day:

/** The number of days from 1970-01-01 to this day; before it, negative. */
fun daysFromCivil(year: Int, month: Int, day: Int): Long {
    val y = if (month <= 2) year.toLong() - 1 else year.toLong()
    val era = y.floorDiv(400)
    val yearOfEra = y - era * 400
    val dayOfYear = (153 * (month + (if (month > 2) -3 else 9)) + 2) / 5 + day - 1
    val dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
    return era * 146_097 + dayOfEra - 719_468
}

/** ISO 8601's day: 1 Monday, 7 Sunday. 1970-01-01 was a Thursday. */
fun dayOfWeek(year: Int, month: Int, day: Int): Int = (daysFromCivil(year, month, day) + 3).mod(7) + 1

The device clock's offset from UTC, an actual on each platform:

// commonMain
expect fun systemUtcOffset(moment: Instant): Duration

// jvmMain and androidMain
actual fun systemUtcOffset(moment: Instant): Duration =
    java.util.TimeZone.getDefault().getOffset(moment.toEpochMilliseconds()).milliseconds

// iosMain
actual fun systemUtcOffset(moment: Instant): Duration =
    NSTimeZone.localTimeZone.secondsFromGMTForDate(NSDate.dateWithTimeIntervalSince1970(moment.epochSeconds.toDouble())).seconds

// webMain (js and wasmJs): Date says how many minutes UTC is ahead, with the sign reversed
@OptIn(ExperimentalWasmJsInterop::class)
private fun minutesUtcIsAhead(milliseconds: Double): Double = js("new Date(milliseconds).getTimezoneOffset()")
actual fun systemUtcOffset(moment: Instant): Duration =
    -minutesUtcIsAhead(moment.toEpochMilliseconds().toDouble()).roundToInt().minutes

Today: it is the day (Clock.System.now() + systemUtcOffset(now)).epochSeconds.floorDiv(86_400); the reverse arithmetic that turns this number into a year, a month and a day is civilFromDays in PantrixDate.kt.

5. What the helper does not do

6. Testing

7. Pitfalls

8. Verification

Tests

WhatWhere
The validation of a day, the day of the week, the number of days from 1970 (±800,000 days), adding months and years, ordering, ISO writing and reading, a moment's day, the rangeui/foundation/src/commonTest/.../date/PantrixDateTest.kt (JVM, Android, iOS, JS, Wasm)
Waiting until midnight, the day that turns at midnightTodayTest
The screen's clock: its day and time, the moment summer time ends, equality, its default, the clock rememberToday reads and a changing clockPantrixClockTest
The components that read the screen's clock: the time field's picker, the elastic date picker's public API, the charts (cartesian, polar, radar, Gantt)TimeTextFieldTest, ElasticDatePickerTest, ChartScreenClockTest
Month and day names, the long date, the week's first day, the refusal of wrong packsPantrixStringsTest
The device's clock offsetSystemTimeTest, SystemTimeOnTheDesktopTest
The validation of a time, ordering, ISO writing and reading, a moment's time, a moment from a day and a time (summer-time nights included)ui/foundation/src/commonTest/.../date/PantrixTimeTest.kt
12- and 24-hour writing, the time wordsPantrixStringsTest
The time picker: the columns, the bounds, the night range, the press, the keys, the digits typed, the Tab stops, the centring, the screen reader, the colours, the styleui/components/src/commonTest/.../TimePicker*Test.kt, TimeColumnsTest.kt
The calendar: the month's places, the keys, the Tab stop, the range, the years, the screen reader, the colours, the styleui/components/src/commonTest/.../DatePicker*Test.kt, CalendarMonthTest.kt
The date field: digits and separators, the formats, Arabic digits, a half day, its own errors, the app's error, a value from outside, the state, choosing from the calendar and the events, Alt+↓, Escape, Enter, read-only, disabled, sheet and dialog, how the layout is read out, a form, the filled boxui/components/src/commonTest/.../DateTextFieldTest.kt, DateFieldFormatTest.kt
The time field: the colon, the zero before, its own errors, the bounds and a range across midnight, a value from outside, 12 hours and the button for the half of the day, A and P, every press in the picker and the events, the focus on the current time with Alt+↓, closing with Enter, read-only, pasting, the state, a form, the filled box, how the layout is read outui/components/src/commonTest/.../TimeTextFieldTest.kt, TimeFieldTextTest.kt
The charts' time axis (it uses the same day arithmetic)ChartTimeTest, ChartTimeAxisTest
Sliding points: shifting, rounding, the week's start (1, 6, 7), the summer-time night, the reading of the range, writing and reading date math, the fraction of a secondui/foundation/src/commonTest/.../date/PantrixTimePointTest.kt
The words of a span and their plurals (English, Turkish, Arabic)PantrixTimeWordsTest
The elastic date picker: the range's name and ends, pressing the name, the quick menu, ‹ ›, a waiting edit, ends out of order, None, Refresh and onRefresh, loading, enabled, a new range, the tab turning the end, the calendar, the half-hour times and the moment typed, 12 hours, sheet, dialog, a panel with no title, the icon in a narrow room, the colours, the style, the recently used, writing and reading a momentui/components/src/commonTest/.../ElasticDatePicker*Test.kt

Looked at by hand

Not verified

9. Where it is in this repository

10. What is left open

In docs/OPEN_ITEMS.md: the time fields (PantrixTimeTextField, PantrixTimeFilledTextField; their skeletons stand), a date field with two ends, named time zones, the device's 12- or 24-hour setting, reading dates and times from text by language; in the elastic date picker, the error of a Refresh pressed by hand, and making general the popover option that lets a press outside through.