Search code examples
javadatetimetimezone-offsetgregorian-calendar

Behaviour of GregorianCalendar.set() with daylight saving times


While troubleshooting a glitch, something strange in this method behavior appears.


Context

Some countries use to save some daylight by shifting time. For example, in the timezone "Europe/Paris", each year, time shifts 1 hour forwards end March and 1 hour backwards end October, both between 2 AM and 3 AM. This causes, for example, the date October, the 30th 2016 at 02:15 AM to exist twice.

Fortunately, both dates does not have the same timestamp (ms) amount, neither readable representation :

  • Before time shift : Sun Oct 30 02:15:00 CEST 2016 (+0200 from UTC)
  • After time shift : Sun Oct 30 02:15:00 CET 2016 (+0100 from UTC)

Issue

After instantiating a GregorianCalendar object at the Paris timezone (using SimpleDateFormat), we get our 02:15 AM before backward shift as expected.

But if we want to set minutes to this object using .set(), the +0200 offset information gets corrupted to +0100 ("same time", but after time shift) Is there any means of doing it this way, as the method .add() actually preserves the offset information?

Example

    // Instantiation
    GregorianCalendar gc1 = new GregorianCalendar(TimeZone.getTimeZone("Europe/Paris"));
    gc1.setTime(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss Z").parse("2016-10-30 02:15:00 +0200"));
    GregorianCalendar gc2 = (GregorianCalendar) gc1.clone();
    
    System.out.println(gc1.getTime()); // Output : Sun Oct 30 02:15:00 CEST 2016 ; OK
    System.out.println(gc2.getTime()); // Output : Sun Oct 30 02:15:00 CEST 2016 ; OK
    
    // Set/add minutes
    gc1.set(Calendar.MINUTE, 10);
    gc2.add(Calendar.MINUTE, 10);
    
    System.out.println(gc1.getTime()); // Output : Sun Oct 30 02:10:00 CET 2016 ; Unexpected
    System.out.println(gc2.getTime()); // Output : Sun Oct 30 02:25:00 CEST 2016 ; OK

Solution

  • An ugly alternative is to subtract 1 hour from gc1 after setting the value:

    gc1.set(Calendar.MINUTE, 10);
    gc1.add(Calendar.HOUR, -1);
    

    The result will be Sun Oct 30 02:10:00 CEST 2016.

    Unfortunately that seems to be the best solution available for Calendar API. This behaviour of set method was reported as a bug, and the recommendation in JDK bug tracker was to use add to "fix" it:

    During the "fall-back" period, Calendar doesn't support disambiguation and the given local time is interpreted as standard time.

    To avoid the unexpected DST to standard time change, call add() to reset the value.

    I'm using Java 8, so it seems that this bug was never fixed.


    Java new Date/Time API

    The old classes (Date, Calendar and SimpleDateFormat) have lots of problems and design issues, and they're being replaced by the new APIs.

    If you're using Java 8, consider using the new java.time API. It's easier, less bugged and less error-prone than the old APIs.

    If you're using Java <= 7, you can use the ThreeTen Backport, a great backport for Java 8's new date/time classes. And for Android, there's the ThreeTenABP (more on how to use it here).

    The code below works for both. The only differences are:

    • the package names (in Java 8 is java.time and in ThreeTen Backport (or Android's ThreeTenABP) is org.threeten.bp), but the classes and methods names are the same.
    • conversion from/to the old Calendar API

    To convert a GregorianCalendar to the new API, you can do:

    // Paris timezone
    ZoneId zone = ZoneId.of("Europe/Paris");
    // convert GregorianCalendar to ZonedDateTime
    ZonedDateTime z = Instant.ofEpochMilli(gc1.getTimeInMillis()).atZone(zone);
    

    In Java 8, you can also do:

    ZonedDateTime z = gc1.toInstant().atZone(zone);
    

    To get the date in the same format produced by java.util.Date you can use a DateTimeFormatter:

    DateTimeFormatter fmt = DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss z yyyy", Locale.ENGLISH);
    System.out.println(fmt.format(z));
    

    The output is:

    Sun Oct 30 02:15:00 CEST 2016

    I used a java.util.Locale to force the locale to English, so the month name and day of week are formatted correclty. If you don't specify a locale, it'll use the system's default, and it's not guaranteed to always be English (and the default can also be changed without notice, even at runtime, so it's better to always make it explicit which one you're using).

    With this API you can add or set the minutes easily:

    // change the minutes to 10
    ZonedDateTime z2 = z.withMinute(10);
    System.out.println(fmt.format(z2)); // Sun Oct 30 02:10:00 CEST 2016
    
    // add 10 minutes
    ZonedDateTime z3 = z.plusMinutes(10);
    System.out.println(fmt.format(z3)); // Sun Oct 30 02:25:00 CEST 2016
    

    The output will be:

    Sun Oct 30 02:10:00 CEST 2016
    Sun Oct 30 02:25:00 CEST 2016

    Note that in the new API, classes are immutable, so with and plus methods return a new instance.

    To convert a ZonedDateTime back to GregorianCalendar, you can do:

    gc1.setTimeInMillis(z.toInstant().toEpochMilli());
    

    In java 8, you can also do:

    gc1 = GregorianCalendar.from(z);
    

    To parse the input 2016-10-30 02:15:00 +0200, you must use another formatter:

    DateTimeFormatter parser = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XX")
        .withZone(ZoneId.of("Europe/Paris"));
    ZonedDateTime z = ZonedDateTime.parse("2016-10-30 02:15:00 +0200", parser);
    

    I had to set the timezone using withZone method, because just the offset +0200 is not enough to determine it (more than one timezone can use this same offset and the API can't decide which one to use)