diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Instant.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Instant.kt index e14ca38a..1502653e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Instant.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Instant.kt @@ -1,11 +1,15 @@ package press.mantra.compose.extensions +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil import kotlinx.datetime.format +import kotlinx.datetime.format.DayOfWeekNames import kotlinx.datetime.format.MonthNames import kotlinx.datetime.format.char import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock import kotlin.time.Instant fun Instant.toFormattedTimeAndDateString(): String { @@ -26,4 +30,59 @@ fun Instant.toFormattedTimeAndDateString(): String { year() } ) -} \ No newline at end of file +} + +/** + * A chat list's clock: the least that still says which line is newer. + * + * The row already carries the message itself, so the timestamp only has to place it — + * and a full date on today's message crowds out the words it belongs to. Coarser the + * further back it goes, and never coarser than the reader can resolve: a weekday is + * unambiguous for six days and no longer, and a bare day and month for a year. + * + * A timestamp ahead of [now] gets a date rather than a time. Relay clocks disagree and + * an event can arrive stamped in the future; rendering that as "14:05" would put it in + * a today it does not belong to. + */ +fun Instant.toChatListTimestampString(now: Instant = Clock.System.now()): String { + val timeZone = TimeZone.currentSystemDefault() + val dateTime = toLocalDateTime(timeZone) + val today = now.toLocalDateTime(timeZone).date + val daysAgo = dateTime.date.daysUntil(today) + + return when { + daysAgo == 0 -> dateTime.format( + LocalDateTime.Format { + hour() + char(':') + minute() + } + ) + + daysAgo == 1 -> "Yesterday" + + daysAgo in 2..6 -> dateTime.date.format( + LocalDate.Format { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + } + ) + + dateTime.year == today.year -> dateTime.date.format( + LocalDate.Format { + day() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + } + ) + + else -> dateTime.date.format( + LocalDate.Format { + day() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + year() + } + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/extensions/ChatListTimestampTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/extensions/ChatListTimestampTest.kt new file mode 100644 index 00000000..5c265707 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/extensions/ChatListTimestampTest.kt @@ -0,0 +1,97 @@ +package press.mantra.compose.extensions + +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +/** + * The clock on a chat list row. It has one job -- placing a message relative to now -- + * and each rung is only unambiguous over a bounded span: a bare time means nothing on + * a message from last week, a weekday means nothing past six days, and a day and month + * mean nothing past a year. Reading a rung too far is the failure mode, and it is silent. + * + * Every case is anchored to the local day rather than to a fixed instant, because the + * boundaries are local midnights and the test would otherwise pass or fail on the + * machine's zone. + */ +class ChatListTimestampTest { + + private val timeZone = TimeZone.currentSystemDefault() + + /** Midday today, local, so no case lands on a boundary by accident. */ + private val now = Instant.parse("2026-09-06T00:00:00Z") + .toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone) + 12.hours + + private fun daysBefore(days: Int) = now - days.days + + @Test + fun `today shows the time of day`() { + val formatted = daysBefore(0).toChatListTimestampString(now) + + assertEquals( + now.toLocalDateTime(timeZone).let { + "${it.hour.toString().padStart(2, '0')}:${it.minute.toString().padStart(2, '0')}" + }, + formatted + ) + } + + @Test + fun `yesterday is named rather than dated`() { + assertEquals("Yesterday", daysBefore(1).toChatListTimestampString(now)) + } + + /** + * Two through six days. A seventh would be this weekday again, which reads as today. + */ + @Test + fun `the rest of the last week shows a weekday`() { + (2..6).forEach { days -> + val formatted = daysBefore(days).toChatListTimestampString(now) + + assertEquals(3, formatted.length, "$days days ago should be an abbreviated weekday") + assertTrue( + formatted.none { it.isDigit() }, + "$days days ago should be a weekday, not a date: $formatted" + ) + } + } + + /** A week out the weekday has stopped meaning anything, so the date takes over. */ + @Test + fun `beyond a week shows a day and month`() { + val formatted = daysBefore(7).toChatListTimestampString(now) + val date = daysBefore(7).toLocalDateTime(timeZone).date + + assertEquals("${date.day} ${date.month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3)}", formatted) + } + + /** Past a year the day and month repeat, so the year has to be said. */ + @Test + fun `a message from another year carries its year`() { + val formatted = daysBefore(400).toChatListTimestampString(now) + val date = daysBefore(400).toLocalDateTime(timeZone).date + + assertTrue( + formatted.endsWith(" ${date.year}"), + "a message from ${date.year} should say so: $formatted" + ) + } + + /** + * Relay clocks disagree and an event can arrive stamped ahead of this device. Showing + * it as a bare time would file it under a today it does not belong to. + */ + @Test + fun `a timestamp from the future gets a date rather than a time`() { + val formatted = (now + 2.days).toChatListTimestampString(now) + + assertTrue(':' !in formatted, "a future timestamp should not read as a time today: $formatted") + } +}