Kotlin / kotlinx-datetime

KotlinX multiplatform date/time library
Apache License 2.0
2.44k stars 101 forks source link

Add unambiguous LocalDateTime.toInstant #461

Open simonolander opened 1 week ago

simonolander commented 1 week ago

As the documentation states, the function LocalDateTime.toInstant(timeZone: TimeZone) can be ambiguous.

Returns an instant that corresponds to this civil date/time value in the specified timeZone.

Note that the conversion is not always well-defined. There can be the following possible situations:

  • There's only one instant that has this date/time value in the timeZone. In this case, the conversion is unambiguous.
  • There's no instant that has this date/time value in the timeZone. Such a situation appears when the time zone experiences a transition from a lesser to a greater offset. In this case, the conversion is performed with the lesser (earlier) offset, as if the time gap didn't occur yet.
  • There are two possible instants that can have this date/time components in the timeZone. In this case, the earlier instant is returned.

I would be greatly helped by a function that returns all the instants that correspond to the LocalDateTime in the specified time zone. For example something with the following signature:

fun LocalDateTime.toInstants(timeZone: TimeZone): List<Instant>

If there is no such instant, the resulting list would be empty.

Would you consider adding this to the library?

dkhalanskyjb commented 1 week ago

Sure, we're open to adding this. Could you share the business logic you need this for?

simonolander commented 1 week ago

Could you share the business logic you need this for?

I have recurring events that happen during certain time windows, e.g. between 12:00 and 18:00, or 02:00 and 02:30, local time. My need is to compute the start and end of each of these windows as pairs of instants for a given set of days.

The first example is straight forward, but the second example event happens twice or never certain days, and I need to be able to account for that.

dkhalanskyjb commented 1 week ago

You've omitted the most interesting part: what are you planning to do to account for that? What's going to happen to the zero, one, or two Instant values afterwards? We need to know this to decide what the actual API would look like. It could be val instants = localDateTime.toInstants(zone), as you suggested, but it could also be localDateTime.toLaterInstantOrNull(zone) + localDateTime.toEarlierInstantOrNull(zone), or localDateTime.toInstant(onGap = { ... }, onOverlap = { instantBefore, instantAfter -> ... }) (or { offsetBefore, offsetAfter -> ... }?), or localDateTime.toInstant(zone, resolver) for some interface InstantResolver (what would be in that interface is also not immediately obvious)... There are plenty of options that are ultimately equivalent, and we need to have a good understanding of how the function would ultimately be used to make an informed decision.

simonolander commented 1 week ago

My use cases are the following:

I hope this answers your question!

As you say, there are many approaches that are ultimately equivalent. I think that my suggestion would be most ergonomic for my use case, but I could likely work with all the alternatives you proposed.

dkhalanskyjb commented 1 week ago

Let's double-check my understanding using specific examples.

the next occurrence doesn't happen due to a gap.

So, for an event like 02:00-02:30, if 02:00 doesn't exist because 01:15 jumped directly to 02:15 (or 02:30 doesn't exist for similar reasons), the event is skipped?

In case of an overlap, this duration may be larger than normal.

  1. For an event like 02:00-02:30, if 02:15 is followed by 01:16, do you want to display 1 hour + 30 minutes as the remaining time the first time it's 02:00?
  2. For an event like 02:00-02:30, if 03:15 is followed by 02:15, do you want to display 1 hour + 30 minutes as the remaining time when it's 02:00?

If the answers are "yes, yes, no", then I believe you can implement that with today's API.

simonolander commented 1 week ago

Good examples, I think they highlight an issue with my proposed solution.

So, for an event like 02:00-02:30, if 02:00 doesn't exist because 01:15 jumped directly to 02:15 (or 02:30 doesn't exist for similar reasons), the event is skipped?

In this example, the event would not be skipped, but shortened to last for 15 minutes, between 02:15 and 02:30.

For an event like 02:00-02:30, if 02:15 is followed by 01:16, do you want to display 1 hour + 30 minutes as the remaining time the first time it's 02:00?

No, because there's a period of inactivity between two periods of activity.

In this case, the event would be active between 02:00 and 02:15 before the shift, inactive between 01:16 and 02:00 after the shift, and active between 02:00 and 02:30 after the shift.

The remaining time is 15 minutes at 02:00 before the shift, counting down to zero as the clock approaches 02:15.

For an event like 02:00-02:30, if 03:15 is followed by 02:15, do you want to display 1 hour + 30 minutes as the remaining time when it's 02:00?

No, again because of the gap between the occurrences. If the event was 02:00-04:00, then yes, the remaining would be 3 hours due the hour gained during the shift.

Your examples made me realize that I need to know the instant when the shift in daylight savings time happens, not just translate certain LocalDateTimes to instants.

dkhalanskyjb commented 1 week ago

It looks like the specific problem you want to solve is: find all Instant values that map to the given range of LocalDateTime values, probably in the form of List<Pair<Instant, Instant>> (ordered list of non-overlapping ranges of Instant values). Is that so?

simonolander commented 6 days ago

Is that so?

Yes, I believe the problem can be stated this way.

One approach I'm imagining is this:

  1. Identify all the Instant values that correspond to the LocalDateTime when the event starts or ends, as well as the instants where there's a shift in DST.
  2. Sort the instants, and sample the midpoint of each consecutive pair to see if the event is active at that time. The instants in the span of the pair will either all be active or inactive.
  3. If there are consecutive spans of activity or inactivity, join them together by discarding the redundant instant.