Search code examples
javarecurringjava-timerecurring-events

Recurring Events with Java Time (java.time or any time library)


I'm modeling recurring events in Java with a very simple recurrence pattern (every x days or weeks). Given an event object with startDateTime, endDateTime and the recurrence in days (which has the type Period from the java.time package), I wanna find out whether an event occurs at a given date (taking DST into account).

Some background on "taking DST into account" (after requests in the comments):

  • The events are used to schedule work shifts of employees.
  • Employees work 24/7 and in shifts (that are usually 8 hours long)
  • The night shift starts on 0:00 and ends on 8:00. Therefore, on DST change, the shift can be either 7 hours or 9 hours long. Under no circumstances should there be gaps.

I'm currently doing this in a not so performant way:

public class Event {
  ZonedDateTime start;
  ZonedDateTime end;
  Period recurrence;  // e.g. 7 days

  public boolean includes(ZonedDateTime dateTime) {
    ZonedDateTime tmpStart = startDate;
    ZoneDateTime tmpEnd = endDate;
    do {
        if (dateTime.isAfter(tmpStart) && dateTime.isBefore(tmpEnd))
            return true;
        tmpStart = tmpStart.plus(recurrence);
        tmpEnd = tmpEnd.plus(recurrence);
    } while (dateTime.isAfter(tmpStart));

    return false;
  }
}

Example For the following event

start = 2015-09-07T00:00
end = 2015-09-07T08:00
recurrence = Period.ofDays(7)

calling includes produces the following result:

assertTrue(event.includes(2015-09-14T01:00)
assertTrue(event.includes(2015-09-21T01:00)
assertFalse(event.includes(2015-09-21T09:00)

What would be a performant way to do this (as mentioned, taking DST into account)? I also wanna show the events on a calendar.

Update: The possible duplicate uses the exact same algorithm I've used above. Is there a faster way to do this without looping through all dates?


Solution

  • Avoid the use of type ZonedDateTime - see my update below

    Provided your recurrent period always consists of days only, I see following optimization (especially for the case if your start date-time is much earlier than your dateTime-argument):

      public boolean includes(ZonedDateTime dateTime) {
        ZonedDateTime tmpStart = start;
        ZonedDateTime tmpEnd = end;
    
        int distance = (int) ChronoUnit.DAYS.between(start, dateTime) - 1;
        if (distance > 0) {
            int factor = (int) (distance / recurrence.getDays());
            if (factor > 0) {
                Period quickAdvance = recurrence.multipliedBy(factor);
                tmpStart = start.plus(quickAdvance);
                tmpEnd = end.plus(quickAdvance);
            }
        }
    
        while (!tmpStart.isAfter(dateTime)) { // includes equality - okay for you?
            if (tmpEnd.isAfter(dateTime)) {
                return true;
            }
            tmpStart = tmpStart.plus(recurrence);
            tmpEnd = tmpEnd.plus(recurrence);
        }
    
        return false;
      }
    

    About DST, well as long as you are fine with the standard strategy of JDK to push forward invalid local times by the size of the gap (change from winter to summer time) the class ZonedDateTime fits into this scheme. For overlaps, this class offers the special methods withEarlierOffsetAtOverlap() and withLaterOffsetAtOverlap().

    However, these features are not really relevant for the question how to find out if a given recurrent interval contains a given date-time because the timezone correction will be applied on all involved date-time-objects in the same way (start, end and dateTime) provided all objects have the same timezone. DST is instead important if you want to determine the duration between start and end (or tmpStart and tmpEnd).

    Update:

    I have discovered a hidden bug which is related to daylight-saving effects. In detail:

    ZoneId zone = ZoneId.of("Europe/Berlin");
    ZonedDateTime start = LocalDate.of(2015, 3, 25).atTime(2, 0).atZone(zone); // inclusive
    ZonedDateTime end = LocalDate.of(2015, 3, 25).atTime(10, 0).atZone(zone); // exclusive
    Period recurrence = Period.ofDays(2);
    
    ZonedDateTime test = LocalDate.of(2015, 3, 31).atTime(2, 30).atZone(zone); // exclusive
    System.out.println("test=" + test); // test=2015-03-31T02:30+02:00[Europe/Berlin]
    
    start = start.plus(recurrence);
    end = end.plus(recurrence);
    System.out.println("start + 2 days = " + start); // 2015-03-27T02:00+01:00[Europe/Berlin]
    System.out.println("end + 2 days =   " + end);   // 2015-03-27T10:00+01:00[Europe/Berlin]
    
    start = start.plus(recurrence); // <- DST change to summer time!!!
    end = end.plus(recurrence);
    System.out.println("start + 4 days = " + start); // 2015-03-29T03:00+02:00[Europe/Berlin]
    System.out.println("end + 4 days =   " + end);   // 2015-03-29T10:00+02:00[Europe/Berlin]
    
    start = start.plus(recurrence);
    end = end.plus(recurrence);
    System.out.println("start + 6 days = " + start); // 2015-03-31T03:00+02:00[Europe/Berlin]
    System.out.println("end + 6 days =   " + end);   // 2015-03-31T10:00+02:00[Europe/Berlin]
    
    boolean includes = !start.isAfter(test) && end.isAfter(test);
    System.out.println("includes=" + includes); // false (should be true!!!)
    

    Repeated addition of a period to ZonedDateTime can shift the local time interval for ever due to DST-effects. But fixed work time schedules are usually defined in terms of local timestamps (in the example from 2 AM to 10 AM). So applying a kind of universal timestamp on a basically local time issue is inherently buggy.

    The right datatype to solve the problem of matching work time intervals is: LocalDateTime:

      public boolean includes(LocalDateTime dateTime) {
        LocalDateTime tmpStart = start;
        LocalDateTime tmpEnd = end;
    
        int distance = (int) ChronoUnit.DAYS.between(start, dateTime) - 1;
        if (distance > 0) {
            int factor = (int) (distance / recurrence.getDays());
            if (factor > 0) {
                Period quickAdvance = recurrence.multipliedBy(factor);
                tmpStart = start.plus(quickAdvance);
                tmpEnd = end.plus(quickAdvance);
            }
        }
    
        while (!tmpStart.isAfter(dateTime)) {
            if (tmpEnd.isAfter(dateTime)) {
                return true;
            }
            tmpStart = tmpStart.plus(recurrence);
            tmpEnd = tmpEnd.plus(recurrence);
        }
    
        return false;
      }
    

    Is it also the right datatype to determine the physical real work time in hours? Yes if you combine a LocalDateTime with a timezone.

    int minutes = (int) ChronoUnit.MINUTES.between(start.atZone(zone), end.atZone(zone));
    System.out.println("hours=" + minutes / 60); // 7 (one hour less due to DST else 8)
    

    Conclusion:

    The type ZonedDateTime has many disadvantages. One example is time arithmetic involved here. Most DST-related problems can be better tackled by choosing a combination of LocalDateTime and explicit timezone parameters.

    About resolving of invalid local times:

    Good question. JSR-310 only offers one built-in transition strategy for gaps so the final result of resolving an invalid local time 02:30 would be 3:30, not 3:00. That is also what the type ZonedDateTime does. Citation:

    ...the local date-time is adjusted to be later by the length of the gap. For a typical one hour daylight savings change, the local date-time will be moved one hour later into the offset typically corresponding to "summer".

    However, following workaround can be applied to achieve the next valid time (be careful, the documentation of ZoneRules.getTransition(LocalDateTime) shows a wrong code schema and example):

    LocalDateTime ldt = LocalDateTime.of(2015, 3, 29, 2, 30, 0, 0);
    ZoneRules rules = ZoneId.of("Europe/Berlin").getRules();
    ZoneOffsetTransition conflict = rules.getTransition(ldt);
    if (conflict != null && conflict.isGap()) {
        ldt = conflict.getDateTimeAfter();
    }
    System.out.println(ldt); // 2015-03-29T03:00
    

    For comparison (because you have originally also asked for a solution in other libraries): Joda-Time would only throw an exception if a local time is invalid, probably not what you want. In my library Time4J, I have defined the type PlainTimestamp corresponding to LocalDateTime and solved the resolving problem this way (no if-else-constructions):

    import static net.time4j.tz.GapResolver.NEXT_VALID_TIME;
    import static net.time4j.tz.OverlapResolver.EARLIER_OFFSET;
    
    PlainTimestamp tsp = PlainTimestamp.of(2015, 3, 29, 2, 30);
    Moment nextValidTime = // equivalent of java.time.Instant
      tsp.in(Timezone.of(EUROPE.BERLIN).with(NEXT_VALID_TIME.and(EARLIER_OFFSET)));
    tsp = nextValidTime.toZonalTimestamp(EUROPE.BERLIN);
    System.out.println(tsp);
    // 2015-03-29T03 (minute is zero and therefore left out here)