core-time: shared date/time helpers (DayWindow + Instant formatting)
First module of floret-kit, the shared Material 3 Expressive design system + plumbing for the Floret app family. core-time is a pure-Kotlin JVM library (DayWindow local-day boundaries + locale/zone-aware Instant formatting), extracted from Agendula. Standalone build + 2 unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.floret.time
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Locale-aware display formatting for the [kotlin.time.Instant]s the domain
|
||||
* uses. Kept tiny and dependency-free; the data layer stores instants, the UI
|
||||
* renders them in the device's zone and locale.
|
||||
*/
|
||||
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
|
||||
private fun Instant.atSystemZone() =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
||||
.atZone(ZoneId.systemDefault())
|
||||
|
||||
/** Medium localized date, e.g. "18 Jun 2026". */
|
||||
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter)
|
||||
|
||||
/** Short localized time, e.g. "14:30". */
|
||||
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
|
||||
|
||||
/** Date alone for all-day items, otherwise date + time. */
|
||||
fun Instant.formatDateTime(allDay: Boolean): String =
|
||||
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.floret.time
|
||||
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Local-day boundaries used by smart-list predicates. Pure + testable. */
|
||||
object DayWindow {
|
||||
|
||||
/** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
|
||||
fun today(now: Instant, zone: ZoneId): Pair<Instant, Instant> {
|
||||
val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
|
||||
.atZone(zone)
|
||||
.toLocalDate()
|
||||
val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.floret.time
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
class DayWindowTest {
|
||||
|
||||
@Test
|
||||
fun `today spans exactly 24h in UTC and contains now`() {
|
||||
val zone = ZoneId.of("UTC")
|
||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
||||
val (start, end) = DayWindow.today(now, zone)
|
||||
|
||||
assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
|
||||
assertThat(now >= start).isTrue()
|
||||
assertThat(now < end).isTrue()
|
||||
// In UTC, midnight aligns to a multiple of one day since the epoch.
|
||||
assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start is local midnight in a non-UTC zone`() {
|
||||
val zone = ZoneId.of("Europe/Berlin")
|
||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
||||
val (start, end) = DayWindow.today(now, zone)
|
||||
|
||||
val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
|
||||
assertThat(startLocal.hour).isEqualTo(0)
|
||||
assertThat(startLocal.minute).isEqualTo(0)
|
||||
assertThat(now >= start).isTrue()
|
||||
assertThat(now < end).isTrue()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user