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):
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?
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)