From 25b90331a312c795bcb20abd23be7a08acefb403 Mon Sep 17 00:00:00 2001 From: mustafadakhel Date: Fri, 25 Jul 2025 13:07:12 +0300 Subject: [PATCH 1/4] Add support for ordinal day formatting with customizable suffixes --- .../src/format/DateTimeFormatBuilder.kt | 11 ++++- core/common/src/format/LocalDateFormat.kt | 41 +++++++++++++++++++ .../samples/format/LocalDateFormatSamples.kt | 40 ++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/core/common/src/format/DateTimeFormatBuilder.kt b/core/common/src/format/DateTimeFormatBuilder.kt index d77021f5..03bd800e 100644 --- a/core/common/src/format/DateTimeFormatBuilder.kt +++ b/core/common/src/format/DateTimeFormatBuilder.kt @@ -100,9 +100,18 @@ public sealed interface DateTimeFormatBuilder { */ public fun day(padding: Padding = Padding.ZERO) + /** + * A day-of-month number with an ordinal suffix (for example, "1st", "2nd", "3rd", "4th"). + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.dayOrdinal + */ + public fun dayOrdinal(names: DayOrdinalNames) + /** @suppress */ @Deprecated("Use 'day' instead", ReplaceWith("day(padding = padding)")) - public fun dayOfMonth(padding: Padding = Padding.ZERO) { day(padding) } + public fun dayOfMonth(padding: Padding = Padding.ZERO) { + day(padding) + } /** * A day-of-week name (for example, "Thursday"). diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt index c472ea19..44ef8f4a 100644 --- a/core/common/src/format/LocalDateFormat.kt +++ b/core/common/src/format/LocalDateFormat.kt @@ -210,6 +210,43 @@ private class DayDirective(private val padding: Padding) : override fun hashCode(): Int = padding.hashCode() } +private class OrdinalDayDirective( + private val names: DayOrdinalNames = DayOrdinalNames.ENGLISH, +) : NamedUnsignedIntFieldFormatDirective(DateFields.day, names.names, "dayOrdinalName") { + + override val builderRepresentation: String + get() = + "${DateTimeFormatBuilder.WithDate::day.name}(${names.toKotlinCode()})" + + override fun equals(other: Any?): Boolean = other is OrdinalDayDirective && names.names == other.names.names + override fun hashCode(): Int = names.names.hashCode() +} + +public class DayOrdinalNames(public val names: List) { + init { + require(names.size == 31) { "Ordinal day suffixes must contain exactly 31 elements" } + } + + public companion object { + /** The English default: "1st", "2nd", "3rd", … */ + public val ENGLISH: DayOrdinalNames = DayOrdinalNames(List(31) { i -> + val d = i + 1 + when { + d % 100 in 11..13 -> "${d}th" + d % 10 == 1 -> "${d}st" + d % 10 == 2 -> "${d}nd" + d % 10 == 3 -> "${d}rd" + else -> "${d}th" + } + }) + } +} + +private fun DayOrdinalNames.toKotlinCode(): String = when (this.names) { + DayOrdinalNames.ENGLISH.names -> "DayOrdinalNames.${DayOrdinalNames.Companion::ENGLISH.name}" + else -> names.joinToString(", ", "DayOrdinalNames(", ")", transform = String::toKotlinCode) +} + private class DayOfYearDirective(private val padding: Padding) : UnsignedIntFieldFormatDirective( DateFields.dayOfYear, @@ -271,6 +308,10 @@ internal interface AbstractWithDateBuilder : AbstractWithYearMonthBuilder, DateT addFormatStructureForDate(structure) } + override fun dayOrdinal(names: DayOrdinalNames) { + addFormatStructureForDate(BasicFormatStructure(OrdinalDayDirective(names))) + } + override fun day(padding: Padding) = addFormatStructureForDate(BasicFormatStructure(DayDirective(padding))) diff --git a/core/common/test/samples/format/LocalDateFormatSamples.kt b/core/common/test/samples/format/LocalDateFormatSamples.kt index 0a03c98c..6480394a 100644 --- a/core/common/test/samples/format/LocalDateFormatSamples.kt +++ b/core/common/test/samples/format/LocalDateFormatSamples.kt @@ -25,6 +25,46 @@ class LocalDateFormatSamples { check(spacePaddedDays.format(LocalDate(2021, 1, 31)) == "31/01/2021") } + @Test + fun ordinalDay() { + // Using ordinal day with the default English‑suffix formatter + val defaultOrdinalDays = LocalDate.Format { + dayOrdinal(DayOrdinalNames.ENGLISH); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + check(defaultOrdinalDays.format(LocalDate(2021, 1, 1)) == "1st Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 2)) == "2nd Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 3)) == "3rd Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 4)) == "4th Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 11)) == "11th Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 21)) == "21st Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 22)) == "22nd Jan") + check(defaultOrdinalDays.format(LocalDate(2021, 1, 31)) == "31st Jan") + } + + @Test + fun ordinalDayWithCustomNames() { + // Using ordinal day with a custom formatter that always falls back to "th" + val customOrdinalDays = LocalDate.Format { + dayOrdinal( + names = DayOrdinalNames( + List(31) { + val d = it + 1 + when (d) { + 1 -> "1st" + 2 -> "2nd" + 3 -> "3rd" + else -> "${d}th" + } + } + )); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + check(customOrdinalDays.format(LocalDate(2021, 1, 1)) == "1st Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 2)) == "2nd Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 3)) == "3rd Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 22)) == "22th Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 31)) == "31th Jan") + } + @Test fun dayOfWeek() { // Using strings for day-of-week names in a custom format From aaaf80fa5dda5e32be6d4562129cb9ca1ee892b0 Mon Sep 17 00:00:00 2001 From: mustafadakhel Date: Fri, 25 Jul 2025 13:29:05 +0300 Subject: [PATCH 2/4] Add more tests for day ordinal formatting --- .../common/test/format/LocalDateFormatTest.kt | 55 ++++++++--- .../samples/format/LocalDateFormatSamples.kt | 97 +++++++++++++------ 2 files changed, 108 insertions(+), 44 deletions(-) diff --git a/core/common/test/format/LocalDateFormatTest.kt b/core/common/test/format/LocalDateFormatTest.kt index 3666cb50..02e6faa8 100644 --- a/core/common/test/format/LocalDateFormatTest.kt +++ b/core/common/test/format/LocalDateFormatTest.kt @@ -7,9 +7,11 @@ package kotlinx.datetime.test.format -import kotlinx.datetime.* +import kotlinx.datetime.LocalDate import kotlinx.datetime.format.* -import kotlin.test.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class LocalDateFormatTest { @@ -233,29 +235,52 @@ class LocalDateFormatTest { @Test fun testDoc() { val format = LocalDate.Format { - year() - char(' ') - monthName(MonthNames.ENGLISH_ABBREVIATED) - char(' ') - day() + year() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + day() } assertEquals("2020 Jan 05", format.format(LocalDate(2020, 1, 5))) } @Test - fun testEmptyDayOfWeekNames() { - val names = DayOfWeekNames.ENGLISH_FULL.names - for (i in 0 until 7) { - val newNames = (0 until 7).map { if (it == i) "" else names[it] } - assertFailsWith { DayOfWeekNames(newNames) } + fun testIdenticalDayOfWeekNames() { + assertFailsWith { + DayOfWeekNames("Mon", "Tue", "Tue", "Thu", "Fri", "Sat", "Sun") } } @Test - fun testIdenticalDayOfWeekNames() { - assertFailsWith { - DayOfWeekNames("Mon", "Tue", "Tue", "Thu", "Fri", "Sat", "Sun") + fun testDayOrdinalFormatting() { + val format = LocalDate.Format { + dayOrdinal(DayOrdinalNames.ENGLISH); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + val testCases = listOf( + LocalDate(2021, 1, 1) to "1st Jan", + LocalDate(2021, 1, 2) to "2nd Jan", + LocalDate(2021, 1, 3) to "3rd Jan", + LocalDate(2021, 1, 4) to "4th Jan", + LocalDate(2021, 1, 11) to "11th Jan", + LocalDate(2021, 1, 21) to "21st Jan", + LocalDate(2021, 1, 22) to "22nd Jan", + LocalDate(2021, 1, 23) to "23rd Jan", + LocalDate(2021, 1, 31) to "31st Jan" + ) + for ((date, expected) in testCases) { + assertEquals(expected, format.format(date), "Failed for $date") + } + } + + @Test + fun testDayOrdinalCustomNames() { + val customNames = DayOrdinalNames(List(31) { i -> "${i + 1}th" }) + val format = LocalDate.Format { + dayOrdinal(customNames); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) } + assertEquals("1th Jan", format.format(LocalDate(2021, 1, 1))) + assertEquals("2th Jan", format.format(LocalDate(2021, 1, 2))) + assertEquals("31th Jan", format.format(LocalDate(2021, 1, 31))) } private fun test(strings: Map>>, format: DateTimeFormat) { diff --git a/core/common/test/samples/format/LocalDateFormatSamples.kt b/core/common/test/samples/format/LocalDateFormatSamples.kt index 6480394a..3feca1a3 100644 --- a/core/common/test/samples/format/LocalDateFormatSamples.kt +++ b/core/common/test/samples/format/LocalDateFormatSamples.kt @@ -5,9 +5,10 @@ package kotlinx.datetime.test.samples.format -import kotlinx.datetime.* +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import kotlinx.datetime.format.* -import kotlin.test.* +import kotlin.test.Test class LocalDateFormatSamples { @Test @@ -41,30 +42,6 @@ class LocalDateFormatSamples { check(defaultOrdinalDays.format(LocalDate(2021, 1, 31)) == "31st Jan") } - @Test - fun ordinalDayWithCustomNames() { - // Using ordinal day with a custom formatter that always falls back to "th" - val customOrdinalDays = LocalDate.Format { - dayOrdinal( - names = DayOrdinalNames( - List(31) { - val d = it + 1 - when (d) { - 1 -> "1st" - 2 -> "2nd" - 3 -> "3rd" - else -> "${d}th" - } - } - )); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) - } - check(customOrdinalDays.format(LocalDate(2021, 1, 1)) == "1st Jan") - check(customOrdinalDays.format(LocalDate(2021, 1, 2)) == "2nd Jan") - check(customOrdinalDays.format(LocalDate(2021, 1, 3)) == "3rd Jan") - check(customOrdinalDays.format(LocalDate(2021, 1, 22)) == "22th Jan") - check(customOrdinalDays.format(LocalDate(2021, 1, 31)) == "31th Jan") - } - @Test fun dayOfWeek() { // Using strings for day-of-week names in a custom format @@ -131,9 +108,11 @@ class LocalDateFormatSamples { @Test fun names() { // Obtaining the list of day of week names - check(DayOfWeekNames.ENGLISH_ABBREVIATED.names == listOf( - "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" - )) + check( + DayOfWeekNames.ENGLISH_ABBREVIATED.names == listOf( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" + ) + ) } @Test @@ -158,4 +137,64 @@ class LocalDateFormatSamples { check(format.format(LocalDate(2021, 1, 13)) == "2021-01-13, Wed") } } + + class DayOrdinalSamples { + @Test + fun usage() { + // Using ordinal day with the default English‑suffix formatter + val format = LocalDate.Format { + dayOrdinal(DayOrdinalNames.ENGLISH); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + check(format.format(LocalDate(2021, 1, 13)) == "2021-01-13, Wed") + } + + @Test + fun ordinalDayWithCustomNames() { + // Using ordinal day with a custom formatter that always falls back to "th" + val customOrdinalDays = LocalDate.Format { + dayOrdinal( + names = DayOrdinalNames( + List(31) { + val d = it + 1 + when (d) { + 1 -> "1st" + 2 -> "2nd" + 3 -> "3rd" + else -> "${d}th" + } + } + )); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED) + } + check(customOrdinalDays.format(LocalDate(2021, 1, 1)) == "1st Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 2)) == "2nd Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 3)) == "3rd Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 22)) == "22th Jan") + check(customOrdinalDays.format(LocalDate(2021, 1, 31)) == "31th Jan") + } + + @Test + fun names() { + // Obtaining the list of day of week names + check( + DayOrdinalNames.ENGLISH.names == listOf( + "1st", "2nd", "3rd", "4th", "5th", "6th", "7th", + "8th", "9th", "10th", "11th", "12th", "13th", + "14th", "15th", "16th", "17th", "18th", "19th", + "20th", "21st", "22nd", "23rd", "24th", "25th", + "26th", "27th", "28th", "29th", "30th", "31st" + ) + ) + } + + @Test + fun invalidListSize() { + // Attempting to create a DayOrdinalNames with an invalid list size + try { + DayOrdinalNames(listOf("1st", "2nd", "3rd")) // only 3 names, should throw + check(false) // should not reach here + } catch (e: Throwable) { + check(e is IllegalArgumentException) + } + } + } } From d9b417c58f99940d85f4f01cf4c5e3d7ed233ff4 Mon Sep 17 00:00:00 2001 From: mustafadakhel Date: Fri, 25 Jul 2025 13:40:57 +0300 Subject: [PATCH 3/4] Improve day ordinal formatting documentation and enhance DayOrdinalNames class --- .../src/format/DateTimeFormatBuilder.kt | 2 +- core/common/src/format/LocalDateFormat.kt | 33 +++++++++++++++++-- .../samples/format/LocalDateFormatSamples.kt | 4 +-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/core/common/src/format/DateTimeFormatBuilder.kt b/core/common/src/format/DateTimeFormatBuilder.kt index 03bd800e..c08d74f6 100644 --- a/core/common/src/format/DateTimeFormatBuilder.kt +++ b/core/common/src/format/DateTimeFormatBuilder.kt @@ -101,7 +101,7 @@ public sealed interface DateTimeFormatBuilder { public fun day(padding: Padding = Padding.ZERO) /** - * A day-of-month number with an ordinal suffix (for example, "1st", "2nd", "3rd", "4th"). + * A day-of-month ordinal number. * * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.dayOrdinal */ diff --git a/core/common/src/format/LocalDateFormat.kt b/core/common/src/format/LocalDateFormat.kt index 44ef8f4a..54897526 100644 --- a/core/common/src/format/LocalDateFormat.kt +++ b/core/common/src/format/LocalDateFormat.kt @@ -222,9 +222,38 @@ private class OrdinalDayDirective( override fun hashCode(): Int = names.names.hashCode() } -public class DayOrdinalNames(public val names: List) { + +/** + * A description of how the names of ordinal days are formatted. + * + * Instances of this class are typically used as arguments to [DateTimeFormatBuilder.WithDate.dayOrdinal]. + * + * A predefined instance is available as [ENGLISH]. + * You can also create custom instances using the constructor. + * + * An [IllegalArgumentException] will be thrown if the list does not contain exactly 31 elements. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.usage + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.customNames + */ +public class DayOrdinalNames( + /** + * A list of the names of ordinal days from 1st to 31st. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.names + */ + public val names: List +) { init { - require(names.size == 31) { "Ordinal day suffixes must contain exactly 31 elements" } + /** + * The list must contain exactly 31 elements, one for each day of the month. + * The names are expected to be in the order from 1st to 31st. + * + * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.DayOrdinalNamesSamples.invalidListSize + */ + require(names.size == 31) { + "Day ordinal names must contain exactly 31 elements, one for each day of the month" + } } public companion object { diff --git a/core/common/test/samples/format/LocalDateFormatSamples.kt b/core/common/test/samples/format/LocalDateFormatSamples.kt index 3feca1a3..1c172e6b 100644 --- a/core/common/test/samples/format/LocalDateFormatSamples.kt +++ b/core/common/test/samples/format/LocalDateFormatSamples.kt @@ -138,7 +138,7 @@ class LocalDateFormatSamples { } } - class DayOrdinalSamples { + class DayOrdinalNamesSamples { @Test fun usage() { // Using ordinal day with the default English‑suffix formatter @@ -149,7 +149,7 @@ class LocalDateFormatSamples { } @Test - fun ordinalDayWithCustomNames() { + fun customNames() { // Using ordinal day with a custom formatter that always falls back to "th" val customOrdinalDays = LocalDate.Format { dayOrdinal( From a41bca93a1a64d2b452908db73e21e036aef3f66 Mon Sep 17 00:00:00 2001 From: mustafadakhel Date: Fri, 25 Jul 2025 14:17:29 +0300 Subject: [PATCH 4/4] Remove unrelated accidental changes --- .../src/format/DateTimeFormatBuilder.kt | 6 ++--- .../common/test/format/LocalDateFormatTest.kt | 25 ++++++++++++------- .../samples/format/LocalDateFormatSamples.kt | 13 ++++------ 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/core/common/src/format/DateTimeFormatBuilder.kt b/core/common/src/format/DateTimeFormatBuilder.kt index c08d74f6..192ccf4a 100644 --- a/core/common/src/format/DateTimeFormatBuilder.kt +++ b/core/common/src/format/DateTimeFormatBuilder.kt @@ -101,7 +101,7 @@ public sealed interface DateTimeFormatBuilder { public fun day(padding: Padding = Padding.ZERO) /** - * A day-of-month ordinal number. + * A day-of-month ordinal. * * @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.dayOrdinal */ @@ -109,9 +109,7 @@ public sealed interface DateTimeFormatBuilder { /** @suppress */ @Deprecated("Use 'day' instead", ReplaceWith("day(padding = padding)")) - public fun dayOfMonth(padding: Padding = Padding.ZERO) { - day(padding) - } + public fun dayOfMonth(padding: Padding = Padding.ZERO) { day(padding) } /** * A day-of-week name (for example, "Thursday"). diff --git a/core/common/test/format/LocalDateFormatTest.kt b/core/common/test/format/LocalDateFormatTest.kt index 02e6faa8..78159eb0 100644 --- a/core/common/test/format/LocalDateFormatTest.kt +++ b/core/common/test/format/LocalDateFormatTest.kt @@ -7,11 +7,9 @@ package kotlinx.datetime.test.format -import kotlinx.datetime.LocalDate +import kotlinx.datetime.* import kotlinx.datetime.format.* -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith +import kotlin.test.* class LocalDateFormatTest { @@ -235,15 +233,24 @@ class LocalDateFormatTest { @Test fun testDoc() { val format = LocalDate.Format { - year() - char(' ') - monthName(MonthNames.ENGLISH_ABBREVIATED) - char(' ') - day() + year() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + day() } assertEquals("2020 Jan 05", format.format(LocalDate(2020, 1, 5))) } + @Test + fun testEmptyDayOfWeekNames() { + val names = DayOfWeekNames.ENGLISH_FULL.names + for (i in 0 until 7) { + val newNames = (0 until 7).map { if (it == i) "" else names[it] } + assertFailsWith { DayOfWeekNames(newNames) } + } + } + @Test fun testIdenticalDayOfWeekNames() { assertFailsWith { diff --git a/core/common/test/samples/format/LocalDateFormatSamples.kt b/core/common/test/samples/format/LocalDateFormatSamples.kt index 1c172e6b..91efbd04 100644 --- a/core/common/test/samples/format/LocalDateFormatSamples.kt +++ b/core/common/test/samples/format/LocalDateFormatSamples.kt @@ -5,10 +5,9 @@ package kotlinx.datetime.test.samples.format -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.* import kotlinx.datetime.format.* -import kotlin.test.Test +import kotlin.test.* class LocalDateFormatSamples { @Test @@ -108,11 +107,9 @@ class LocalDateFormatSamples { @Test fun names() { // Obtaining the list of day of week names - check( - DayOfWeekNames.ENGLISH_ABBREVIATED.names == listOf( - "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" - ) - ) + check(DayOfWeekNames.ENGLISH_ABBREVIATED.names == listOf( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" + )) } @Test