Find next occurrence of a time, like TemporalAdjuster

735 views Asked by At

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  |
----------------------------------------
3

There are 3 answers

3
Sweeper On BEST ANSWER

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.

2
Andreas On

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.

0
Jeffrey Blattman On

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).