Skip to content

Commit fbb2187

Browse files
authored
Implement parsing and formatting days-of-year (#417)
Fixes #414
1 parent 8664b70 commit fbb2187

8 files changed

+158
-14
lines changed

core/common/src/format/DateTimeComponents.kt

+17-2
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,12 @@ public class DateTimeComponents internal constructor(internal val contents: Date
305305
set(value) {
306306
contents.date.isoDayOfWeek = value?.isoDayNumber
307307
}
308-
// /** Returns the day-of-year component of the date. */
309-
// public var dayOfYear: Int
308+
309+
/**
310+
* The day-of-year component of the date.
311+
* @sample kotlinx.datetime.test.samples.format.DateTimeComponentsSamples.dayOfYear
312+
*/
313+
public var dayOfYear: Int? by ThreeDigitNumber(contents.date::dayOfYear)
310314

311315
/**
312316
* The hour-of-day (0..23) time component.
@@ -604,4 +608,15 @@ private class TwoDigitNumber(private val reference: KMutableProperty0<Int?>) {
604608
}
605609
}
606610

611+
private class ThreeDigitNumber(private val reference: KMutableProperty0<Int?>) {
612+
operator fun getValue(thisRef: Any?, property: KProperty<*>) = reference.getValue(thisRef, property)
613+
614+
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int?) {
615+
require(value === null || value in 0..999) {
616+
"${property.name} must be a three-digit number, got '$value'"
617+
}
618+
reference.setValue(thisRef, property, value)
619+
}
620+
}
621+
607622
private val emptyDateTimeComponentsContents = DateTimeComponentsContents()

core/common/src/format/DateTimeFormatBuilder.kt

+7
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ public sealed interface DateTimeFormatBuilder {
9090
*/
9191
public fun dayOfWeek(names: DayOfWeekNames)
9292

93+
/**
94+
* A day-of-year number, from 1 to 366.
95+
*
96+
* @sample kotlinx.datetime.test.samples.format.LocalDateFormatSamples.dayOfYear
97+
*/
98+
public fun dayOfYear(padding: Padding = Padding.ZERO)
99+
93100
/**
94101
* An existing [DateTimeFormat] for the date part.
95102
*

core/common/src/format/LocalDateFormat.kt

+62-10
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,15 @@ internal interface DateFieldContainer {
201201
var monthNumber: Int?
202202
var dayOfMonth: Int?
203203
var isoDayOfWeek: Int?
204+
var dayOfYear: Int?
204205
}
205206

206207
private object DateFields {
207208
val year = GenericFieldSpec(PropertyAccessor(DateFieldContainer::year))
208209
val month = UnsignedFieldSpec(PropertyAccessor(DateFieldContainer::monthNumber), minValue = 1, maxValue = 12)
209210
val dayOfMonth = UnsignedFieldSpec(PropertyAccessor(DateFieldContainer::dayOfMonth), minValue = 1, maxValue = 31)
210211
val isoDayOfWeek = UnsignedFieldSpec(PropertyAccessor(DateFieldContainer::isoDayOfWeek), minValue = 1, maxValue = 7)
212+
val dayOfYear = UnsignedFieldSpec(PropertyAccessor(DateFieldContainer::dayOfYear), minValue = 1, maxValue = 366)
211213
}
212214

213215
/**
@@ -217,14 +219,40 @@ internal class IncompleteLocalDate(
217219
override var year: Int? = null,
218220
override var monthNumber: Int? = null,
219221
override var dayOfMonth: Int? = null,
220-
override var isoDayOfWeek: Int? = null
222+
override var isoDayOfWeek: Int? = null,
223+
override var dayOfYear: Int? = null,
221224
) : DateFieldContainer, Copyable<IncompleteLocalDate> {
222225
fun toLocalDate(): LocalDate {
223-
val date = LocalDate(
224-
requireParsedField(year, "year"),
225-
requireParsedField(monthNumber, "monthNumber"),
226-
requireParsedField(dayOfMonth, "dayOfMonth")
227-
)
226+
val year = requireParsedField(year, "year")
227+
val date = when (val dayOfYear = dayOfYear) {
228+
null -> LocalDate(
229+
year,
230+
requireParsedField(monthNumber, "monthNumber"),
231+
requireParsedField(dayOfMonth, "dayOfMonth")
232+
)
233+
else -> LocalDate(year, 1, 1).plus(dayOfYear - 1, DateTimeUnit.DAY).also {
234+
if (it.year != year) {
235+
throw DateTimeFormatException(
236+
"Can not create a LocalDate from the given input: " +
237+
"the day of year is $dayOfYear, which is not a valid day of year for the year $year"
238+
)
239+
}
240+
if (monthNumber != null && it.monthNumber != monthNumber) {
241+
throw DateTimeFormatException(
242+
"Can not create a LocalDate from the given input: " +
243+
"the day of year is $dayOfYear, which is ${it.month}, " +
244+
"but $monthNumber was specified as the month number"
245+
)
246+
}
247+
if (dayOfMonth != null && it.dayOfMonth != dayOfMonth) {
248+
throw DateTimeFormatException(
249+
"Can not create a LocalDate from the given input: " +
250+
"the day of year is $dayOfYear, which is the day ${it.dayOfMonth} of ${it.month}, " +
251+
"but $dayOfMonth was specified as the day of month"
252+
)
253+
}
254+
}
255+
}
228256
isoDayOfWeek?.let {
229257
if (it != date.dayOfWeek.isoDayNumber) {
230258
throw DateTimeFormatException(
@@ -241,16 +269,21 @@ internal class IncompleteLocalDate(
241269
monthNumber = date.monthNumber
242270
dayOfMonth = date.dayOfMonth
243271
isoDayOfWeek = date.dayOfWeek.isoDayNumber
272+
dayOfYear = date.dayOfYear
244273
}
245274

246-
override fun copy(): IncompleteLocalDate = IncompleteLocalDate(year, monthNumber, dayOfMonth, isoDayOfWeek)
275+
override fun copy(): IncompleteLocalDate =
276+
IncompleteLocalDate(year, monthNumber, dayOfMonth, isoDayOfWeek, dayOfYear)
247277

248278
override fun equals(other: Any?): Boolean =
249279
other is IncompleteLocalDate && year == other.year && monthNumber == other.monthNumber &&
250-
dayOfMonth == other.dayOfMonth && isoDayOfWeek == other.isoDayOfWeek
280+
dayOfMonth == other.dayOfMonth && isoDayOfWeek == other.isoDayOfWeek && dayOfYear == other.dayOfYear
251281

252-
override fun hashCode(): Int =
253-
year.hashCode() * 31 + monthNumber.hashCode() * 31 + dayOfMonth.hashCode() * 31 + isoDayOfWeek.hashCode() * 31
282+
override fun hashCode(): Int = year.hashCode() * 923521 +
283+
monthNumber.hashCode() * 29791 +
284+
dayOfMonth.hashCode() * 961 +
285+
isoDayOfWeek.hashCode() * 31 +
286+
dayOfYear.hashCode()
254287

255288
override fun toString(): String =
256289
"${year ?: "??"}-${monthNumber ?: "??"}-${dayOfMonth ?: "??"} (day of week is ${isoDayOfWeek ?: "??"})"
@@ -375,6 +408,22 @@ private class DayDirective(private val padding: Padding) :
375408
override fun hashCode(): Int = padding.hashCode()
376409
}
377410

411+
private class DayOfYearDirective(private val padding: Padding) :
412+
UnsignedIntFieldFormatDirective<DateFieldContainer>(
413+
DateFields.dayOfYear,
414+
minDigits = padding.minDigits(3),
415+
spacePadding = padding.spaces(3),
416+
) {
417+
override val builderRepresentation: String
418+
get() = when (padding) {
419+
Padding.ZERO -> "${DateTimeFormatBuilder.WithDate::dayOfYear.name}()"
420+
else -> "${DateTimeFormatBuilder.WithDate::dayOfYear.name}(${padding.toKotlinCode()})"
421+
}
422+
423+
override fun equals(other: Any?): Boolean = other is DayOfYearDirective && padding == other.padding
424+
override fun hashCode(): Int = padding.hashCode()
425+
}
426+
378427
private class DayOfWeekDirective(private val names: DayOfWeekNames) :
379428
NamedUnsignedIntFieldFormatDirective<DateFieldContainer>(DateFields.isoDayOfWeek, names.names, "dayOfWeekName") {
380429

@@ -432,6 +481,9 @@ internal interface AbstractWithDateBuilder : DateTimeFormatBuilder.WithDate {
432481
override fun dayOfWeek(names: DayOfWeekNames) =
433482
addFormatStructureForDate(BasicFormatStructure(DayOfWeekDirective(names)))
434483

484+
override fun dayOfYear(padding: Padding) =
485+
addFormatStructureForDate(BasicFormatStructure(DayOfYearDirective(padding)))
486+
435487
@Suppress("NO_ELSE_IN_WHEN")
436488
override fun date(format: DateTimeFormat<LocalDate>) = when (format) {
437489
is LocalDateFormat -> addFormatStructureForDate(format.actualFormat)

core/common/src/format/Unicode.kt

+7-1
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,13 @@ internal sealed interface UnicodeFormat {
293293

294294
class DayOfYear(override val formatLength: Int) : DateBased() {
295295
override val formatLetter = 'D'
296-
override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) = unsupportedDirective("day-of-year")
296+
override fun addToFormat(builder: DateTimeFormatBuilder.WithDate) {
297+
when (formatLength) {
298+
1 -> builder.dayOfYear(Padding.NONE)
299+
3 -> builder.dayOfYear(Padding.ZERO)
300+
else -> unknownLength()
301+
}
302+
}
297303
}
298304

299305
class MonthOfYear(override val formatLength: Int) : DateBased() {

core/common/test/format/LocalDateFormatTest.kt

+21
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,27 @@ class LocalDateFormatTest {
209209
test(dates, LocalDate.Formats.ISO_BASIC)
210210
}
211211

212+
@Test
213+
fun testDayOfYear() {
214+
val dates = buildMap<LocalDate, Pair<String, Set<String>>> {
215+
put(LocalDate(2008, 7, 5), ("2008-187" to setOf()))
216+
put(LocalDate(2007, 12, 31), ("2007-365" to setOf()))
217+
put(LocalDate(999, 11, 30), ("0999-334" to setOf()))
218+
put(LocalDate(-1, 1, 2), ("-0001-002" to setOf()))
219+
put(LocalDate(9999, 10, 31), ("9999-304" to setOf()))
220+
put(LocalDate(-9999, 9, 30), ("-9999-273" to setOf()))
221+
put(LocalDate(10000, 8, 1), ("+10000-214" to setOf()))
222+
put(LocalDate(-10000, 7, 1), ("-10000-183" to setOf()))
223+
put(LocalDate(123456, 6, 1), ("+123456-153" to setOf()))
224+
put(LocalDate(-123456, 5, 1), ("-123456-122" to setOf()))
225+
}
226+
test(dates, LocalDate.Format {
227+
year()
228+
char('-')
229+
dayOfYear()
230+
})
231+
}
232+
212233
@Test
213234
fun testDoc() {
214235
val format = LocalDate.Format {

core/common/test/samples/format/DateTimeComponentsSamples.kt

+23
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,27 @@ class DateTimeComponentsSamples {
142142
check(parsedWithoutDayOfWeek.dayOfWeek == null)
143143
}
144144

145+
@Test
146+
fun dayOfYear() {
147+
// Formatting and parsing a date with the day of the year in complex scenarios
148+
val format = DateTimeComponents.Format {
149+
year(); dayOfYear()
150+
}
151+
val formattedDate = format.format {
152+
setDate(LocalDate(2023, 2, 13))
153+
check(year == 2023)
154+
check(dayOfYear == 44)
155+
}
156+
check(formattedDate == "2023044")
157+
val parsedDate = format.parse("2023044")
158+
check(parsedDate.toLocalDate() == LocalDate(2023, 2, 13))
159+
check(parsedDate.year == 2023)
160+
check(parsedDate.dayOfYear == 44)
161+
check(parsedDate.month == null)
162+
check(parsedDate.dayOfMonth == null)
163+
check(parsedDate.dayOfWeek == null)
164+
}
165+
145166
@Test
146167
fun date() {
147168
// Formatting and parsing a date in complex scenarios
@@ -154,6 +175,7 @@ class DateTimeComponentsSamples {
154175
check(month == Month.JANUARY)
155176
check(dayOfMonth == 2)
156177
check(dayOfWeek == DayOfWeek.MONDAY)
178+
check(dayOfYear == 2)
157179
}
158180
check(formattedDate == "2023-01-02")
159181
val parsedDate = format.parse("2023-01-02")
@@ -162,6 +184,7 @@ class DateTimeComponentsSamples {
162184
check(parsedDate.month == Month.JANUARY)
163185
check(parsedDate.dayOfMonth == 2)
164186
check(parsedDate.dayOfWeek == null)
187+
check(parsedDate.dayOfYear == null)
165188
}
166189

167190
@Test

core/common/test/samples/format/LocalDateFormatSamples.kt

+10
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ class LocalDateFormatSamples {
8787
check(format.format(LocalDate(2021, 12, 13)) == "Mon 13/12/2021")
8888
}
8989

90+
@Test
91+
fun dayOfYear() {
92+
// Using day-of-year in a custom format
93+
val format = LocalDate.Format {
94+
year(); dayOfYear()
95+
}
96+
check(format.format(LocalDate(2021, 2, 13)) == "2021044")
97+
check(format.parse("2021044") == LocalDate(2021, 2, 13))
98+
}
99+
90100
@Test
91101
fun date() {
92102
// Using a predefined format for a date in a larger custom format

core/jvm/test/UnicodeFormatTest.kt

+11-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ class UnicodeFormatTest {
4040
"yyyy_MM_dd_HH_mm_ss", "yyyy-MM-d 'at' HH:mm ", "yyyy:MM:dd HH:mm:ss",
4141
"yyyy年MM月dd日 HH:mm:ss", "yyyy年MM月dd日", "dd.MM.yyyy. HH:mm:ss", "ss", "ddMMyyyy",
4242
"yyyyMMdd'T'HHmmss'Z'", "yyyyMMdd'T'HHmmss", "yyyy-MM-dd'T'HH:mm:ssX",
43-
"yyyy-MM-dd'T'HH:mm:ss[.SSS]X" // not in top 100, but interesting, as it contains an optional section
4443
)
4544
val localizedPatterns = listOf(
4645
"MMMM", "hh:mm a", "h:mm a", "dd MMMM yyyy", "dd MMM yyyy", "yyyy-MM-dd hh:mm:ss", "d MMMM yyyy", "MMM",
@@ -71,6 +70,16 @@ class UnicodeFormatTest {
7170
}
7271
}
7372

73+
@Test
74+
fun testOptionalSection() {
75+
checkPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]X")
76+
}
77+
78+
@Test
79+
fun testDayOfYearFormats() {
80+
checkPattern("yyyyDDDHHmm")
81+
}
82+
7483
private fun checkPattern(pattern: String) {
7584
val unicodeFormat = UnicodeFormat.parse(pattern)
7685
val directives = directivesInFormat(unicodeFormat)
@@ -160,6 +169,7 @@ private val dateTimeComponentsTemporalQuery = TemporalQuery { accessor ->
160169
ChronoField.YEAR to { year = it },
161170
ChronoField.MONTH_OF_YEAR to { monthNumber = it },
162171
ChronoField.DAY_OF_MONTH to { dayOfMonth = it },
172+
ChronoField.DAY_OF_YEAR to { dayOfYear = it },
163173
ChronoField.DAY_OF_WEEK to { dayOfWeek = DayOfWeek(it) },
164174
ChronoField.AMPM_OF_DAY to { amPm = if (it == 1) AmPmMarker.PM else AmPmMarker.AM },
165175
ChronoField.CLOCK_HOUR_OF_AMPM to { hourOfAmPm = it },

0 commit comments

Comments
 (0)