3

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?

Community
  • 1
  • 1
Theo
  • 3,074
  • 7
  • 39
  • 54
  • (While the linked question uses Joda and you are asking about Java.Time, they are still possibly considerable a duplicate) – Diego Martinoia Sep 11 '15 at 10:05
  • @DiegoMartinoia Yeah, it actually doesn't matter which time library is used. The answer to the linked question uses the exact same algorithm I've used above. I was hoping for a more elegant / faster solution that doesn't loop through all dates. But maybe I'm optimising prematurely here. – Theo Sep 11 '15 at 10:23
  • Since you appear to be using dates only: why use ZonedDateTime? And why the reference to DST? The presumably simpler LocalDate should be up to what you need. - Rather than advancing start and end (2 dates) you might reduce the date in question by the same amount, which is less work. - A more sophisticated optimization would use a conversion to JulianDate and some simple integer (mod) arithmetic. But the conversion requires some work, too, and would pay only if there are many iterations in the current algorithm. – laune Sep 11 '15 at 10:45
  • @laune I'm using ZonedDateTime to take DST into account when advancing the dates. E.g. an event from 2 - 5 am on DST change lasts 2 or 4 hours. Am I missing something here? Can I use LocaDateTime instead? – Theo Sep 11 '15 at 10:55
  • Why time? The events are on *days*, "recurrence in days", to quote you. And you wand to investigate an *day*, not an hour or point in time. Then, LocalDate should do. – laune Sep 11 '15 at 11:09
  • @laune No, events occur on a specific time, and therefore, have a start and end time. Using the method `Event.include(ZonedDateTime)`, I wanna find out whether an event is occurring at the given dateTime. – Theo Sep 11 '15 at 11:12
  • 1
    You might edit you question... So only the "shift" duration is in (integer) days? The dateTime that you need to investigate: can it be any value between 00:00 and 23:59:59? -- However, doing all calculations in UTC avoids the DST issue. -- So, only my proposal to use reduction of the time-to-investigate holds. – laune Sep 11 '15 at 11:32
  • The only thing that is in an integer is the recurrence period. Say a "shift" is from 8am - 4pm and recurs every 7 days. I'll think about using UTC. Regarding your suggestion of reducing the number of time advancements: thanks! Will use that as a first optimisation. – Theo Sep 11 '15 at 11:43
  • If you really care about events impacted by DST (usually in the night) then ask yourself what will you do with an invalid local time of let's say 02:30? Shall this generated event time throw an exception? Shall it be moved forward to 03:30 (Standard JDK behaviour)? Or shall it be moved forward to the next valid time - 03:00 (indicating the use of an external library with that capability - for simplicity)? Otherwise - without DST - `LocalDateTime` is a good choice. – Meno Hochschild Sep 11 '15 at 12:42
  • 1
    About `ZonedDateTime`, it offers the methods `withEarlierOffsetAtOverlap()` and `withLaterOffsetAtOverlap()`. For gaps there is no alternative strategy (at least not via a simple approach) as mentioned in my previous comment. Please specify what "taking DST into account" means for you. – Meno Hochschild Sep 11 '15 at 12:52
  • @MenoHochschild I updated my question. Hope it's now more clear. – Theo Sep 11 '15 at 13:04
  • I don't think I quite get it. So if a shift starts at 2015-01-01 00:00 and ends at 2015-01-01 08:00 and recurs for 5 days, all you really care to do is see if the time part is between the start date time and end date time. If not, forget it, if so: then the date part of the comparison must be between the start date and the end date + the period. Or am I really missing the point of the question? Maybe a concrete example in the question is in order? – John Kuhns Sep 11 '15 at 13:42
  • @Theo By `startDate` and `endDate` did you mean dates to define a work-week? Or did you mean date-time values to define a work shift? – Basil Bourque Sep 11 '15 at 15:52
  • @BasilBourque The latter. These are date-time values that define a shift (e.g. 8am - 4pm). – Theo Sep 11 '15 at 16:44
  • @JohnKuhns the event object just stores start and times + the recurrence pattern (e.g. every 2 days). For a given date-time in the future, I wanna find out whether the occurs or not. – Theo Sep 11 '15 at 16:47
  • 1
    @Theo Doesn't that Event object also have either a starting date or a day-of-week? – Basil Bourque Sep 11 '15 at 17:02
  • 1
    I still don't see how this is supposed to work. Some concrete numbers in the question - expected input and expected output - would make it clearer to me. – John Kuhns Sep 11 '15 at 17:04
  • @BasilBourque The event object only contains the listed properties: startDateTime, endDateTime and recurrence, where recurrence is the number of days. – Theo Sep 11 '15 at 22:57
  • @BasilBourque like I said, there is a start date. But no day of week. – Theo Sep 11 '15 at 23:07
  • @MenoHochschild how would convert an invalid LocalDateTime, say 2:30 at spring daylight savings transition, to next valid time (3:00)? I didn't find a method for that in the java.time package. – Theo Sep 29 '15 at 10:21
  • @Theo Please have a look at my updated answer. – Meno Hochschild Sep 29 '15 at 11:58

2 Answers2

2

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)
Meno Hochschild
  • 42,708
  • 7
  • 104
  • 126
  • Thank you! You're assumptions are all true and I like your idea of "quick advance". I think it's exactly what I was looking for, but I will test your algorithm against my suite of unit tests first and provide feedback. – Theo Sep 15 '15 at 20:07
  • @Theo Please also check if the changed condition `tmpStart.isAfter(dateTime)` is okay for you. My assumption was to go with half-open-intervals. Your original code seems to be based on open intervals but then you would have gaps with exactly one point in time (for example at 8 AM). – Meno Hochschild Sep 16 '15 at 09:45
  • yeah, I discovered that issue too and now assume that startDate is inclusive and endDate exclusive. I didn't find time to check your code yet, as my boss assigned me an emergency issue. Will get back to you as soon as I'm done with that issue. – Theo Sep 16 '15 at 11:08
  • Sorry for the delay. Your algorithm passed all my unit tests. And thank you for your hint on using LocalDateTime instead of ZonedDateTime. I will also try to do some performance tests showing the improvement of your algorithm. – Theo Sep 24 '15 at 11:49
  • Thanks for providing the details on how to convert an invalid LocalDateTime to the next valid one. That's funny, I just stumbled upon Time4J and now you mention you're the creator of it. The proposed solution with T4J looks clean. – Theo Sep 29 '15 at 12:07
  • One more question: why the `- 1` in your days between calculation? – Theo Sep 29 '15 at 13:42
  • @Theo It was just for safety. If you leave it out and can prove that your JUnit-tests still pass then it is fine. But I believe (without having tested it) that -1 is necessary (the idea is to avoid the quick advance being beyond the while-condition due to different clock times of start-time and condition-timestamp). – Meno Hochschild Sep 29 '15 at 14:03
1

Your Question is not precisely defined, as the many comments show. So I'll make some assumptions.

The core of your Questions seems to be:

find out whether an event occurs at a given date (taking DST into account)

  • Let's assume you mean the date-only. More specifically let's assume you mean the given date is in the same time zone as the Event startDateTime instance member.
  • Let's assume the shifts are contained within the date (no overlap with yesterday or tomorrow), as you stated.

In such a situation, DST is irrelevant if we care only about the date. If fact the time-of-days are entirely irrelevant. All we need is the number of days. The modulo will tell us if the given date is a multiple of the recurrence number.

ZoneId zoneId = ZoneId.of( "America/Montreal" );
// CRITICAL: Read the class doc to understand the policy used by java.time to handle DST cutovers when moving a LocalDateTime value into a zoned value.
// http://docs.oracle.com/javase/8/docs/api/java/time/ZonedDateTime.html#of-java.time.LocalDateTime-java.time.ZoneId-
ZonedDateTime start = ZonedDateTime.of( LocalDateTime.parse( "2015-09-07T00:00" ), zoneId );
ZonedDateTime stop = ZonedDateTime.of( LocalDateTime.parse( "2015-09-07T08:00" ), zoneId );
Integer recurrence = 2;

LocalDate givenDate = LocalDate.now( zoneId );  // The given date being in the same time zone as our shift start date-time is a *critical* assumption.
LocalDate startLocalDate = start.toLocalDate( );
Period period = Period.between( startLocalDate, givenDate );
int days = period.getDays( );
int mod = ( days % recurrence );
Boolean eventHappensOnThatDate = ( mod == 0 );

Dump to console.

System.out.println("Does event: " + start   + "/" + stop + " recurring every " + recurrence + " days happen on " + givenDate + " ➙ " + eventHappensOnThatDate );

When run.

Does event: 2015-09-07T00:00-04:00[America/Montreal]/2015-09-07T08:00-04:00[America/Montreal] recurring every 2 days happen on 2015-09-11 ➙ true
Basil Bourque
  • 303,325
  • 100
  • 852
  • 1,154
  • Hm, again, sorry for not being clear enough. The date to test is a date-time. Please have a look at the Event class that I've provided. Your second is assumption is wrong, too. An Event can also span multiple days (e.g. if a shift starts at 10pm and ends at 3am). – Theo Sep 12 '15 at 00:05
  • @Theo If an Event/Shift can span across two dates, how do you want to calculate the recurrence? Add a number of days from the start while ignoring the end of the Event/Shift? – Basil Bourque Sep 12 '15 at 01:29
  • As long as the event is shorter than the recurrence, that's not an issue. Just add the recurrence to both end and start of the event. – Theo Sep 12 '15 at 08:14