Skip to content

Previous/next date and/or time with the given form #325

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
dkhalanskyjb opened this issue Dec 1, 2023 · 12 comments
Open

Previous/next date and/or time with the given form #325

dkhalanskyjb opened this issue Dec 1, 2023 · 12 comments
Milestone

Comments

@dkhalanskyjb
Copy link
Collaborator

"I have a date or a time, and I want to round/adjust it."

Examples:

@volkert-fastned
Copy link

One practical use case for such functionality: the OCPP specification. It mandates that ISO date/time strings don't have more than 3 decimal points:

number of decimal places SHALL NOT exceed the maximum of 3.

In other words: no more than millisecond precision.

While working with kotlinx-datetime, we actually ran into compatibility problems with vendors that will flat out reject the nanosecond-precise ISO date/time strings that kotlinx.datetime.Instant gets serialized to by kotlinx.serialization.

We could use JSR-310 (java.time) instead, so we could solve it with truncateTo, or we can write a helper function as suggested on StackOverflow, but I was kind of hoping that there would be an equally convenient built-in multiplatform solution for this.

@volkert-fastned
Copy link

volkert-fastned commented Feb 22, 2024

Update:

I found a single-line workaround for forcibly reducing the precision to millisecond-level in serialized ISO date/time strings, at least for timestamps originating from the system clock:

import kotlinx.datetime.Clock
import kotlinx.datetime.Instant

// Will have 6 digits behind the decimal point on Kotlin/JVM, but 3 digits on Kotlin/JS and Kotlin/Native
println(Clock.System.now())

// Will have 3 digits behind the decimal point on Kotlin/JVM, Kotlin/JS and Kotlin/Native
println(Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()))

So Kotlin/JVM appears to appears to be the only Kotlin platform that creates Instants from the system clock at nanosecond precision, although I haven't tried it with Kotlin for Android yet.

@dkhalanskyjb
Copy link
Collaborator Author

@volkert-fastned, since your problem is with system interoperability, the upcoming API for parsing and formatting may solve it: #343

@alghe-global
Copy link

alghe-global commented Nov 23, 2024

I've the use case where I want to check whether a day (LocalDate) is in current week or current month. I don't think there's support for this yet, and I think this issue would help - correct me if I'm wrong. Is there any chance this will be implemented soon?

LE: here's the equivalent code using Java API (in Kotlin) of what I'm trying to do

fun isCurrentDay(timestamp: Long): Boolean {
    val instant = Instant.ofEpochMilli(timestamp)
    val currentLocalDate = LocalDate.now(ZoneId.systemDefault())
    val timestampLocalDate = instant.atZone(ZoneId.systemDefault()).toLocalDate()
    return currentLocalDate == timestampLocalDate
}

fun isCurrentWeek(timestamp: Long): Boolean {
    val instant = Instant.ofEpochMilli(timestamp)
    val currentLocalDate = LocalDate.now(ZoneId.systemDefault())
    val timestampLocalDate = instant.atZone(ZoneId.systemDefault()).toLocalDate()

    val currentWeekStart = currentLocalDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
    val currentWeekEnd = currentWeekStart.plusDays(6)

    return timestampLocalDate in currentWeekStart..currentWeekEnd
}

fun isCurrentMonth(timestamp: Long): Boolean {
    val instant = Instant.ofEpochMilli(timestamp)
    val currentLocalDate = LocalDate.now(ZoneId.systemDefault())
    val timestampLocalDate = instant.atZone(ZoneId.systemDefault()).toLocalDate()

    val currentMonthStart = currentLocalDate.with(TemporalAdjusters.firstDayOfMonth())
    val currentMonthEnd = currentMonthStart.plusDays(
        (currentLocalDate.lengthOfMonth() - 1).toLong()
    )

    return timestampLocalDate in currentMonthStart..currentMonthEnd
}

@dkhalanskyjb
Copy link
Collaborator Author

@alghe-global, here are some kotlinx-datetime implementations:

import kotlinx.datetime.*

fun LocalDate.isSameDay(timestamp: Long): Boolean =
    this == timestampToDateInSystemTimeZone(timestamp)

fun LocalDate.isSameWeek(timestamp: Long): Boolean =
    previousOrSame(DayOfWeek.MONDAY) ==
        timestampToDateInSystemTimeZone(timestamp).previousOrSame(DayOfWeek.MONDAY)

fun LocalDate.isSameMonth(timestamp: Long): Boolean {
    val timestampLocalDate = timestampToDateInSystemTimeZone(timestamp)
    return year == timestampLocalDate.year && month == timestampLocalDate.month
}
/*
// After https://github.com/Kotlin/kotlinx-datetime/pull/457:
fun LocalDate.isSameMonth(timestamp: Long): Boolean =
    yearMonth == timestampToDateInSystemTimeZone(timestamp).yearMonth
*/

private fun timestampToDateInSystemTimeZone(timestampMillis: Long) =
    Instant.fromEpochMilliseconds(timestampMillis).toLocalDateTime(TimeZone.currentSystemDefault()).date
    
// https://github.com/Kotlin/kotlinx-datetime/issues/129#issuecomment-1152301045
private fun LocalDate.previousOrSame(requiredDayOfWeek: DayOfWeek): LocalDate =
    minus((dayOfWeek.isoDayNumber - requiredDayOfWeek.isoDayNumber).mod(7), DateTimeUnit.DAY)

The "current week" use case would indeed benefit from temporal-adjuster-style functionality, but as a workaround, you could copy this implementation of previousOrSame to your code.

@chrisjenx
Copy link

Another missing use case of ZonedDateTime is something like this which we do allot of billing to make sure we doing close to midnight as possible:

request.billingStartDate
                .toJodaDateTime(request.billingTimeZone)
                .withTimeAtStartOfDay(),

To get this, we have to convert to JodaDateTime to be able to work out the midnight in that time zone, LocalDateTime or Instant doens't know this without being timezone aware.

@dkhalanskyjb
Copy link
Collaborator Author

@chrisjenx, did you try https://kotlinlang.org/api/kotlinx-datetime/kotlinx-datetime/kotlinx.datetime/at-start-of-day-in.html ? Also, there is no need to repeat your message in several threads, we are reading everything that's submitted to the issue tracker.

@chrisjenx
Copy link

That only works on getting an Instant, in this case we already have a Instant from our API call. so you have to do this insanity:

Instant -> LocalDateTime -> LocalDate -> atTimeWithStartOfDay -> Instant

Thankfully we send timezones separately in our api requests otherwise this would be impossible, then we also want todo things like this ALL the time esp in testing:

System.now().withTime(7,0,0) // 7am

Which obviously is messy as instant isn't zoned. I know you keep shooting it down, but there needs to be a comparable DateTime/ZonedDateTime to Joda/Java8 API's.

Also I cross posted as wasn't sure which ticket it should go in

@dkhalanskyjb
Copy link
Collaborator Author

so you have to do this insanity:

instant.toLocalDateTime(zone).date.atStartOfDayIn(zone)? I don't see what's insane about this. This line of code captures my intuition behind this operation perfectly:

  • I want the moment of the start (atStartOfDayIn)
  • of the day (.date)
  • that also contained the given moment (instant)
  • in the given time zone (.toLocalDateTime(zone))

If this is too verbose and your business domain has a shorter name for this operation, you can add an extension function with that name in your code.

Thankfully we send timezones separately in our api requests otherwise this would be impossible

Sure, it's impossible to correctly perform this operation without knowing both the instant and the time zone, no matter what API you are using. What is your point here?

then we also want todo things like this ALL the time esp in testing:
System.now().withTime(7,0,0) // 7am

Could you describe this use case in more detail? If there is no 07:00 at that time zone at that moment, what should be returned? Say, if 06:30 jumps directly to 07:30, should the result be 08:30, 07:30, or something else, and why?

I know you keep shooting it down, but there needs to be a comparable DateTime/ZonedDateTime to Joda/Java8 API's.

I think "shooting it down" is an emotionally charged and incorrect description, as I personally am fully in favor of introducing ZonedDateTime as LocalDateTime + TimeZone. Unless, of course, you are talking about a complete equivalent to Java's ZonedDateTime (Instant + TimeZone); in that case, true, I'm shooting it down, as I can't remember seeing anyone provide a single use case for it that wasn't better covered by LocalDateTime + TimeZone. Do you have such a use case?

@chrisjenx
Copy link

If this is too verbose and your business domain has a shorter name for this operation, you can add an extension function with that name in your code.

I'm not the first person to share this sentiment, but the extension function argument was ok when we interacted with legacy java api's, this getting worn a little thin when we're talking about new api's that can/are being built from the ground up.

instant.withTimeAt() really should be a first class function, firstly because it's hard for people coming from other API's to get there, secondly because it's poor on performance when we do these operations inside loops.
Looking at the Github and SO issues, I know I'm not alone in that sentiment of not being clear from a dux point of view.

Another use case which I'll share which shows how this is much easier in Joda/Java but difficult in kotlinx-dt:

fun DateTime.nextDayOfMonth(dayOfMonth: Int, lookahead: Boolean = false): DateTime {
    require(dayOfMonth in 1..31) { "Invalid day of month" }
    val now = this
    var dom = dayOfMonth
    if (dayOfMonth > this.dayOfMonth().maximumValue) {
        dom = this.dayOfMonth().maximumValue
    }
    val nextDayOfMonth = this.withDayOfMonth(dom)
    if (nextDayOfMonth.isBefore(now) || (lookahead && nextDayOfMonth.isEqual(now))) {
        return this.plusMonths(1).withDayOfMonth(dom)
    }
    return nextDayOfMonth
}

Now the datetime equivalent (which don't do lookahead, and we have own methods for leapyears, days in month which don't exist in kt-datetime):

fun Instant.nextDayOfMonth(dayOfMonth: Int, timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant? {
    if (dayOfMonth !in 1..31) return null
    val now = this
    val thisLocalDateTime = this.toLocalDateTime(timeZone)
    val thisMonth = thisLocalDateTime.monthNumber
    val thisDayOfMonth = thisLocalDateTime.dayOfMonth
    val maxDayOfThisMonth = minOf(thisLocalDateTime.let { it.month.numberOfDays(it.year) }, dayOfMonth)

    // Return this instance if the day of month is the same
    if (thisDayOfMonth == maxDayOfThisMonth) return this
    // If the day of month is within this month return that date
    if (thisDayOfMonth < maxDayOfThisMonth) {
        return now.plus((maxDayOfThisMonth - thisDayOfMonth).days)
    }

    // If the day of month is in the past, return the next month's date
    var next = now
    while (next.plus(1.days).toLocalDateTime(timeZone).monthNumber <= thisMonth) {
        next += 1.days
    }
    val maxDayOfNextMonth = minOf(this.toLocalDateTime(timeZone).let { it.month.numberOfDays(it.year) }, dayOfMonth)
    return next.plus(maxDayOfNextMonth.days)
}

Sure, it's impossible to correctly perform this operation without knowing both the instant and the time zone, no matter what API you are using. What is your point here?

I get what you are saying, but with ZonedDateTime, we can deduce and operate on the instance at a parsed level. 2020-01-01T10:10:10-0600 would have it's offset set so means we have rough knowledge of what time zone to operate in for time operations.

Could you describe this use case in more detail? If there is no 07:00 at that time zone at that moment, what should be returned? Say, if 06:30 jumps directly to 07:30, should the result be 08:30, 07:30, or something else, and why?

Most common case is start of day, some places go from 12 -> 1am when time shifts or 1 -> 12am, I think in most cases what Joda does is correct, I specify 12am (00:00) it would put me to 1am as they are the "same" time. Clocks moving back is a little more interesting, specifying a time at say 1am would still be correct, but you move a clock forward 24 hours from 6pm, that would be 5am the next day, but if you plus 1 day that would still be 6pm but if you diffed the hours that would be 25 not 24. Same in reverse, plus 24 hours would put you at 7pm, but if you diffed the hours for plus 1 day it would be 23.

THis is actually calculations we do with scheduling:

fun DateTime.hoursInDay(): Int {
    val start = this.withTimeAtStartOfDay()
    return Hours.hoursBetween(start, start.plusDays(1)).hours
}

Where we will dynamically test how many schedule slots will get created on a given day:

val minutes = Minutes.minutesBetween(
                start.withTimeAtStartOfDay(), start.withTime(23, 59, 59, 999)
            ).minutes
val slots = minutes / defaultDur
assertThat(groups).hasSize(if (start.hoursInDay() > 24) 4 else 3) // today and tomorrow
assertThat(groups[0].bookableGroups).hasSize(1)
            assertThat(groups[0].bookableGroups!![0].bookableSlots).hasSize(slots)

Multiple places will do things like:

val denver = DateTimeZone.forID("America/Denver")
val start = DateTime.now(denver).minusDays(1).withTime(7, 0, 0, 0)

I see your point, you "can" do everything that other API's do, I think where people get caught up is that you have to go back and forth through cumbersome API's and more importantly know how to do those conversions.

The shared above time/date conversions are easy to do and operate on, but I have to do Instant -> LocalDate -> Set Time -> Instant -> plus/minus -> toLocaleDatetTime. It's just not intuitive.

I think "shooting it down" is an emotionally charged and incorrect description, as I personally am fully in favor of introducing ZonedDateTime as LocalDateTime + TimeZone. Unless, of course, you are talking about a complete equivalent to Java's ZonedDateTime (Instant + TimeZone); in that case, true, I'm shooting it down, as I can't remember seeing anyone provide a single use case for it that wasn't better covered by LocalDateTime + TimeZone. Do you have such a use case?

See above, think where it gets heated/frustrated is that people are coming from these API's and are getting stuck when seeing no direct equivalent, then questions feel like being dismissed without example api's. Maybe the outcome here would be a Coming from Joda/Java guide which would have eased my transition dramatically and I touch just about every API (writing scheduling/calendar based stuff) so can only imagine how much easier it would be for those with less knowledge of both api's.
Partially related to the problem, but I have not been able to use the format api's without going to the readme everytime as it just doesn't feel natural to use (at least to me).

@dkhalanskyjb
Copy link
Collaborator Author

@chrisjenx, tl;dr: your problem is that you're using Instant for things where LocalDate works better. If you fix this, your code immediately becomes more manageable, and the conversions that bother you are largely gone.

firstly because it's hard for people coming from other API's to get there

Having zero onboarding is not a goal for kotlinx-datetime, as kotlinx-datetime follows an entirely different design philosophy from java.time. Where java.time attempts to provide an API that succinctly covers all conceivable use cases and more, we attempt to make our API learnable: there is a limited number of entities, each with a clean purpose, and everything you need should be available as a combination of operations on those entities.

This is out of scope of this discussion, but the gist of the reasoning behind this decision was that we saw how java.time APIs were widely used incorrectly due to users not understanding what the operations that "intuitively make sense" actually do. We expose the naked truth, hopefully leading a developer to less buggy code.

because it's poor on performance when we do these operations inside loops

Would be nice if you could provide some benchmarks to substantiate this claim. I don't imagine any significant performance wins would be gained from squashing instant.toLocalDateTime(zone).date.atStartOfDayIn(zone) into one operation: you still have to do essentially the same internally. In fact, ZonedDateTime operations allocate intermediate LocalDateTime instances all the time as well, if 310bp is anything to go by: https://github.com/ThreeTen/threetenbp/blob/b833efe7ac2f1a02c016deb188fd7ce3b124ed2e/src/main/java/org/threeten/bp/ZonedDateTime.java#L1559-L1561

In fact, I believe orthogonal operations make writing performant code easier. Imagine you want to obtain the start of the next day instead. If you know about instant.withStartOfDayIn(zone) but don't know what it actually does internally, you can be tempted to do instant.plus(1, DateTimeUnit.DAY, zone).withStartOfDayIn(zone), but it's a more complex operation in total than the orthogonal equivalent instant.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone). The latter would be preferred over instant.plus(1, DateTimeUnit.DAY, zone).toLocalDateTime(zone).date.atStartOfDayIn(zone) because it's shorter.

Looking at the Github and SO issues, I know I'm not alone in that sentiment of not being clear from a dux point of view.

Sure, but it's not like questions don't get asked all the time about java.time or Joda as well.

fun DateTime.nextDayOfMonth(dayOfMonth: Int, lookahead: Boolean = false): DateTime

Is it expected that DateTime(2025, 9, 30, 0, 0).nextDayOfMonth(31, lookahead = true) returns DateTime(2025, 10, 30, 0, 0) instead of DateTime(2025, 10, 31, 0, 0), or is it a bug? Why is the length of one month affecting the result for the next month?

I'll assume this is a bug (and another data point for not providing such error-prone low-level functions), but please do correct me if I'm wrong, and I'll try to think of a kotlinx-datetime-idiomatic solution that covers your exact case. If this is indeed a bug, I don't see any downsides to the following implementation:

import kotlinx.datetime.*

/**
 * Returns the date >= this, setting day-of-month to [dayOfMonth],
 * unless the length of the month does not allow this,
 * in which case the end of the month is returned.
 * 
 * If [lookahead] is set to `true`, instead of returning a date >= this,
 * a date > this is returned.
 */
fun LocalDate.nextDayOfMonth(dayOfMonth: Int, lookahead: Boolean = false): LocalDate {
    if (lookahead) return plus(1, DateTimeUnit.DAY).nextDayOfMonth(dayOfMonth)
    require(dayOfMonth in 1..31) { "Invalid day of month" }
    val startOfNextMonth = LocalDate(year, month, 1).plus(1, DateTimeUnit.MONTH)
    val endOfMonth = startOfNextMonth.minus(1, DateTimeUnit.DAY)
    return when {
        dayOfMonth >= endOfMonth.dayOfMonth -> endOfMonth
        dayOfMonth >= this.dayOfMonth -> LocalDate(year, month, dayOfMonth)
        else -> startOfNextMonth.nextDayOfMonth(dayOfMonth)
    }
}

fun Instant.nextDayOfMonth(dayOfMonth: Int, zone: TimeZone, lookahead: Boolean = false): Instant {
    val dateTime = toLocalDateTime(zone)
    val newDate = dateTime.date.nextDayOfMonth(dayOfMonth, lookahead)
    return newDate.atTime(dateTime.time).toInstant(zone)
}

With the upcoming YearMonth API (#457), this should be even easier:

fun LocalDate.nextDayOfMonth(dayOfMonth: Int, lookahead: Boolean = false): LocalDate {
    if (lookahead) return plus(1, DateTimeUnit.DAY).nextDayOfMonth(dayOfMonth)
    require(dayOfMonth in 1..31) { "Invalid day of month" }
    val endOfMonth = yearMonth.lastDay
    return when {
        dayOfMonth >= endOfMonth.dayOfMonth -> endOfMonth
        dayOfMonth >= this.dayOfMonth -> yearMonth.onDay(dayOfMonth)
        else -> yearMonth.plusMonth().firstDay.nextDayOfMonth(dayOfMonth)
    }
}

Other snippets you provided also don't look scary to me when translated to kotlinx-datetime:

import kotlinx.datetime.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes

fun LocalDate.dayDurationIn(zone: TimeZone): Duration {
    val start = atStartOfDayIn(zone)
    val end = plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
    return end - start
}

fun LocalDate.hoursInDayIn(zone: TimeZone): Int =
    dayDurationIn(zone).inWholeHours.toInt()

val defaultDuration: Duration = 150.minutes

fun LocalDate.numberOfSlotsIn(zone: TimeZone): Int =
    (dayDurationIn(zone) / defaultDuration).toInt()

fun sevenAmYesterdayAtDenver(): Instant {
    val denver = TimeZone.of("America/Denver")
    return Clock.System.todayIn(denver).minus(1, DateTimeUnit.DAY).atTime(7, 0).toInstant(denver)
}

Note: I haven't actually run any of the functions I've written, so please do test these functions if you want to use them.

with ZonedDateTime, we can deduce and operate on the instance at a parsed level. 2020-01-01T10:10:10-0600 would have it's offset

Parsing this is covered by DateTimeComponents, you can get your offset in kotlinx-datetime today. See the first code block in https://github.com/Kotlin/kotlinx-datetime?tab=readme-ov-file#parsing-and-formatting-partial-compound-or-out-of-bounds-data

I see your point, you "can" do everything that other API's do, I think where people get caught up is that you have to go back and forth through cumbersome API's and more importantly know how to do those conversions.

We are making the conversions that are going to happen anyway explicit in an attempt to simplify the mental model one may have of datetimes. In our research, we found that the wrappers around this model make it easier for bugs to sneak by in innocuous-looking code. There is no way around understanding what the code you are writing actually does.

See above, think where it gets heated/frustrated is that people are coming from these API's and are getting stuck when seeing no direct equivalent, then questions feel like being dismissed without example api's.

There are usage examples for every API provided. In any case, in Kotlin Slack, we are always ready to answer any usage questions, and when it indeed turns out that something is difficult to express in kotlinx-datetime, we do take notes and often implement a solution.

Maybe the outcome here would be a Coming from Joda/Java guide which would have eased my transition dramatically

This would be tough to write. The issue is that often, there really is no straightforward direct equivalent, and the answer "you probably don't need this, please rethink your logic" is likely to only be disappointing to the reader, even if it really is true. I can't quickly think of a way to summarize the problems I've seen people experience when migrating to kotlinx-datetime from java.time or even the approaches to fix them: because of how extensive the java.time API is, everyone's code is slightly different, not to mention that it's frequently buggy.

Partially related to the problem, but I have not been able to use the format api's without going to the readme everytime as it just doesn't feel natural to use (at least to me).

If you somehow find the format strings used by Java natural, you can use those: https://github.com/Kotlin/kotlinx-datetime?tab=readme-ov-file#using-unicode-format-strings-like-yyyy-mm-dd

@dkhalanskyjb
Copy link
Collaborator Author

@chrisjenx, found a behavior in your original snippet that's certainly a bug: DateTime(2025, 1, 31, 0, 0).nextDayOfMonth(30) throws an exception.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants