3

Is there anything in JSR-310 for finding the next occurrence of a given time? I'm really looking for the same thing as this question but for times instead of days.

For example, starting with a date of 2020-09-17 at 06:30 UTC, I'd like to find the next occurrence of 05:30:

LocalTime time = LocalTime.of(5, 30);
ZonedDateTime startDateTime = ZonedDateTime.of(2020, 9, 17, 6, 30, 0, 0, ZoneId.of("UTC"));

ZonedDateTime nextTime = startDateTime.with(TemporalAdjusters.next(time)); // This doesn't exist

in the above, I'd like nextTime to represent 2020-09-18 at 05:30 UTC, i.e. 05:30 the following morning.

To clarify my expectations, all with a time of 05:30:

----------------------------------------
| startDateTime    | expected nextTime |
| 2020-09-07 06:30 | 2020-09-08 05:30  |
| 2020-09-07 05:00 | 2020-09-07 05:30  |
| 2020-09-07 05:30 | 2020-09-08 05:30  |
----------------------------------------
DaveyDaveDave
  • 9,821
  • 11
  • 64
  • 77
  • 1
    So it is a feature similar to what I have already realized in my lib [Time4J](http://time4j.net/javadoc-en/net/time4j/WallTimeElement.html#setToNext-net.time4j.PlainTime-) Well, you have to write your own adjuster in JSR 310... – Meno Hochschild Sep 17 '20 at 12:39

3 Answers3

4

An adjuster like next(LocalTime time) only makes sense for types with both a date and a time.

Java's Time API comes with 4 types like that: LocalDateTime, OffsetDateTime, ZonedDateTime, and Instant.

To fully support all 4, the code need special handling for Instant, since Instant and LocalTime are not directly relatable, and for ZonedDateTime, to handle DST overlap.

This implementation can handle all that:

public static TemporalAdjuster next(LocalTime time) {
    return temporal -> {
        if (temporal instanceof Instant) {
            OffsetDateTime utcDateTime = ((Instant) temporal).atOffset(ZoneOffset.UTC);
            return next(utcDateTime, time).toInstant();
        }
        return next(temporal, time);
    };
}

@SuppressWarnings("unchecked")
private static <T extends Temporal> T next(T refDateTime, LocalTime targetTime) {
    T adjusted = (T) refDateTime.with(targetTime);
    if (refDateTime.until(adjusted, ChronoUnit.NANOS) > 0)
        return adjusted;
    if (adjusted instanceof ChronoZonedDateTime<?>) {
        ChronoZonedDateTime<?> laterOffset = ((ChronoZonedDateTime<?>) adjusted).withLaterOffsetAtOverlap();
        if (laterOffset != adjusted && refDateTime.until(laterOffset, ChronoUnit.NANOS) > 0)
            return (T) laterOffset;
    }
    return (T) refDateTime.plus(1, ChronoUnit.DAYS).with(targetTime);
}

Test (with now() being 2020-09-18 at some time after 10 AM)

System.out.println(LocalDateTime.now().with(next(LocalTime.of(10, 0))));
System.out.println(OffsetDateTime.now().with(next(LocalTime.of(10, 0))));
System.out.println(ZonedDateTime.now().with(next(LocalTime.of(10, 0))));
System.out.println(Instant.now().with(next(LocalTime.of(10, 0))));

Output

2020-09-19T10:00
2020-09-19T10:00-04:00
2020-09-19T10:00-04:00[America/New_York]
2020-09-19T10:00:00Z

Test Overlap

For US Eastern time zone, DST ends at 2:00 AM on Sunday, November 1, 2020.

// We start at 1:45 AM EDT on November 1, 2020
ZoneId usEastern = ZoneId.of("America/New_York");
ZonedDateTime earlierOffset = ZonedDateTime.of(2020, 11, 1, 1, 45, 0, 0, usEastern);
System.out.println(earlierOffset);
// Now we look for next 1:20 AM after the 1:45 AM, and will find 1:20 AM EST
System.out.println(earlierOffset.with(next(LocalTime.of(1, 20))));

Output

2020-11-01T01:45-04:00[America/New_York]
2020-11-01T01:20-05:00[America/New_York]

Even though the time of day appears earlier (1:20 < 1:45), it is actually a later time.

Andreas
  • 154,647
  • 11
  • 152
  • 247
  • I don't think it makes sense to handle `Instant`s to be honest. `Instant`s represent a number of seconds since the epoch, and I wouldn't call the UTC time of an `Instant` the "time component" of an `Instant`. Also, if all we need from `ZonedDateTime` is `withLaterOffsetAtOverlap`, we don't need the unchecked casts, because we can implement that method with just `Temporal`'s methods. e.g. You can get the zone by `adjusted.query(TemporalQueries.zone())`. This will also provide support for `ChronoZonedDateTime`. – Sweeper Sep 19 '20 at 00:44
  • @Sweeper Since `ZonedDateTime` implements `ChronoZonedDateTime`, which declares the `withLaterOffsetAtOverlap()` method, all you need is to case to that instead. --- Why would we want to "get the zone", and then have to do the more complex logic of checking for and applying a later offset, when we can use the method already available to us? --- I see nothing nonsensical about calling "next 10:30am" on an `Instant`, since an `Instant` clearly has a time-of-day. Having a more complete method that can handle `Instant`, if called that way, is a good utility method. – Andreas Sep 21 '20 at 16:28
3

If you just want this to work with LocalDateTimes and LocalTimes, or any other type of Temporal that has a 24-hour day, the logic is quite simple:

public static TemporalAdjuster nextTime(LocalTime time) {
    return temporal -> {
        LocalTime lt = LocalTime.from(temporal);
        if (lt.isBefore(time)) {
            return temporal.with(time);
        } else {
            return temporal.plus(Duration.ofHours(24)).with(time);
        }
    };
}

But doing this for all Temporals that have a time component is actually quite hard. Think about what you'd have to do for ZonedDateTime. Rather than adding 24 hours, we might need to add 23 or 25 hours because DST transitions make a "day" shorter or longer. You can somewhat deal with this by adding a "day" instead:

public static TemporalAdjuster nextTime(LocalTime time) {
    return temporal -> {
        LocalTime lt = LocalTime.from(temporal);
        if (lt.isBefore(time) || !temporal.isSupported(ChronoUnit.DAYS)) {
            return temporal.with(time);
        } else {
            return temporal.plus(1, ChronoUnit.DAYS).with(time);
        }
    };
}

However, it still doesn’t always handle gaps and overlaps correctly. For example, when we are asking for the next 01:30 since 01:31, and there is an overlap transition of 1 hour at 02:00, i.e. the clock goes back an hour at 02:00. The correct answer is to add 59 minutes, but the above code will give us a date time in the next day. To handle this case you'd need to do something complicated like in Andreas' answer.

If you look at the other built in temporal adjusters, they are all pretty simple, so I guess they just didn't want to put in this complexity.

Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • @DaveyDaveDave Yeah, the temporal adjuster in my answer will also work for `ZonedDateTime`s if you stick to UTC, since UTC has 24-hour days. – Sweeper Sep 17 '20 at 10:16
  • A method like `nextTime(LocalTime time)` only makes sense of types with both a date and time. It certainly doesn't make sense for types without a time, and for types without a date you'd just use the input, so that makes no sense either. In standard Java, there are only 4 types with both, i.e. `LocalDateTime`, `OffsetDateTime`, `ZonedDateTime`, and `Instant`, and `Instant` doesn't support `LocalTime.from()`, so that does work with this code. All 3 remaining types support `ChronoUnit.DAYS`, so the check is unnecessary. – Andreas Sep 18 '20 at 15:46
  • @OleV.V. - in my case it's a trivial app for telling an EV when to (or not to) charge, and the times at which prices change are always UTC, so - for my case - not a concern. I can see that this is probably one of many reasons why this isn't included in the core library though. – DaveyDaveDave Sep 20 '20 at 20:37
0

If you use ZonedDateTime as refDateTime, @Andreas's answer amounts to this,

fun ZonedDateTime.next2(targetTime: LocalTime): ZonedDateTime {
  val adjusted = with(targetTime)
  if (isBefore(adjusted)) {
    return adjusted
  }
  return plus(1, ChronoUnit.DAYS).with(targetTime)
}

I tested with the current time (refDateTime) equal to every day between 2020 and 2026, and with the target time set to every hour and minute in the day, and the above returns the same result as @Andreas's answer.

The only caveat (with both solutions) here is that we can't return times that don't exist. E.g. if you ask it for the next occurrence of the time 02:30 on the day before DST start (after 02:30), you get 03:30 on DST start, because 02:30 on DST start doesn't exist (and is actually the "same" time as 03:30 anyway).

Jeffrey Blattman
  • 22,176
  • 9
  • 79
  • 134