Skip to content

Add support for ordinal day formatting #561

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/common/src/format/DateTimeFormatBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ public sealed interface DateTimeFormatBuilder {
*/
public fun day(padding: Padding = Padding.ZERO)

/**
* A day-of-month ordinal.
*
* @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) }
Expand Down
70 changes: 70 additions & 0 deletions core/common/src/format/LocalDateFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,72 @@ private class DayDirective(private val padding: Padding) :
override fun hashCode(): Int = padding.hashCode()
}

private class OrdinalDayDirective(
private val names: DayOrdinalNames = DayOrdinalNames.ENGLISH,
) : NamedUnsignedIntFieldFormatDirective<DateFieldContainer>(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()
}


/**
* 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<String>
) {
init {
/**
* 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 {
/** 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<DateFieldContainer>(
DateFields.dayOfYear,
Expand Down Expand Up @@ -271,6 +337,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)))

Expand Down
32 changes: 32 additions & 0 deletions core/common/test/format/LocalDateFormatTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,38 @@ class LocalDateFormatTest {
}
}

@Test
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<LocalDate, Pair<String, Set<String>>>, format: DateTimeFormat<LocalDate>) {
for ((date, stringsForDate) in strings) {
val (canonicalString, otherStrings) = stringsForDate
Expand Down
76 changes: 76 additions & 0 deletions core/common/test/samples/format/LocalDateFormatSamples.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ 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 dayOfWeek() {
// Using strings for day-of-week names in a custom format
Expand Down Expand Up @@ -118,4 +134,64 @@ class LocalDateFormatSamples {
check(format.format(LocalDate(2021, 1, 13)) == "2021-01-13, Wed")
}
}

class DayOrdinalNamesSamples {
@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 customNames() {
// 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)
}
}
}
}