Suppose you take 2:30 AM at some random day in the year, and add 366 times one day to it, when do you end up? Yes… 367 times if it’s a leap year, wise guys. But still, when do you end up?
Calendar cal = new GregorianCalendar(2020, 11-1, 1, 2, 30);
cal.setTimeZone(TimeZone.getTimeZone("Europe/Rome"));
ZonedDateTime zdt = ZonedDateTime.of(2020, 11, 1, 2, 30, 00, 00, ZoneId.of("Europe/Rome"));
for (int i = 0; i < 366; i++) {
System.out.println(cal.toInstant() + " " + zdt);
cal.add(Calendar.DATE, 1);
zdt = zdt.plusDays(1);
}
Take a guess before reading on!
So given 2021 is not a leap year, adding 366 times one day to 2020-11-1 2:30 would put you in 2021-11-1 2:30, right?
Wrong. The start is as expected (the toInstant() makes the calendar go to UTC, but that is ok. I still need to have a chat with the guy coded the toString() of Calendar):
2020-11-01T01:30:00Z
2020-11-01T02:30+01:00[Europe/Rome]
But the end is not:
2021-11-01T00:30:00Z
2021-11-01T03:30+01:00[Europe/Rome]
After 366 iterations the calendar moved to one hour earlier and the ZonedDateTime one hour later! If you take a closer look at all the data, you see the slip up happening at the transitions to daylight saving time (DST). Funny enough this only happens once; if you iterate for 10 years the slip will stay one hour.
But the result is that if I have an daily recurring appointment, one started in June and one in November on the same time, then in my Google Calendar they will appear at the same time, but will trigger on different times in ical4j.
The cause is simple, ICal has a number of ways to specify a date-time, for example the start of an event in several notations:
DTSTART;TZID=Europe/Rome:20191101T023000
DTSTART:20191101T023000Z
DTSTART;VALUE=DATE:20191101
DTSTART;VALUE=DATE-TIME:20191101T023000Z
With a time zone, without (so in UTC), whole day, and a “what the hell, I’ll just parse it”. The first one is the notation used by Google Calendar for anything recurring.
ICal4j logically converts the start and end time of the VEvent to the best matching Java class, so Calendar (in 3.x) or ZonedDateTime (in 4.x) for the first, and LocalDateTime for the rest. But the example at the beginning just showed that recurring using either Calendar or ZonedDateTime creates a slip on DST. And that is exactly what happened in TeslaTasks; recurring appointments on the same time in Google Calendar triggered either on time, an hour early or late.
Some research showed that the correct way to do recurring is to ALWAYS use LocalDateTime or UTC to do the recurring, and apply the time zone logic afterwards, on the resulting date-times.
Unfortunately ical4j is quite a complex piece of code, supporting all the ins and outs of ICal. And the choice for using Calendar in 3.x is right there at the beginning (parsing) and woven through out the code. The 4.x branch is strict in its parsing, and there sometimes are slip ups in date-time notation in Google Calendar ICals, which makes it fail on for TeslaTasks irrelevant fields. So the easiest way out of this was to write a mini ical implementation, only focussing on VEvents, its start & end values, and recurring.
Luckily DMFS (who are they? But many thanks!) have published a standalone implementation of the recur logic. So with a little bit of ical parsing, TeslaTasks is back on time.
Andres has no idea how wise his tweet was, telling me to just always go to UTC and work from there. 😀