Schedules, Daylight Savings & Historical Time

A synopsis of our basic daylight savings time support for scheduled mode changes, and an obscure bug we encountered and fixed in the Android app as a result. One of my first projects at Canary in late 2016 / early 2017.

Daylight Savings Time Support

One of the features Canary devices support is the ability to automatically transition devices to and from a “night” state at specific times on a repeating, weekly basis. Combined with geofencing, this allows the user to record events while asleep without being inundated with alerts during waking hours. Back in 2017, this functionality was present but limited: daylight savings time wasn’t taken into account. For example, a location in EDT would transition an hour late while daylight time was active.

To address this, we:

  1. Explicitly defined the schedule API’s start and end fields be UTC-based and timezone-naive. The phone would present local times to the user, then translate and post up to the API.

  2. Added the basic logic to look up locations in the given query window. Given a window start and end to operate over:

    1. Find the full set of location timezones present over all known locations.
    2. For each timezone, find the next DST transition after the window start, if one exists.
    3. Calculate the delta = utc_offset_after - utc_offset_before.
    4. If a transition occurred, adjust according to the edge case below.
  3. Added edge case logic to the schedule lookup to cover the “spring forward” and “fall backward” cases. The following example diagrams show the query window elasticity for EST / EDT:

     Spring Forward (positive delta)
     Occurred on 3/8/2020 07:00 UTC
    
     1. 06:54 UTC - 6:59 UTC
    
             [____]
         ----------|xxxxxxxxxx|----------
     Local        02:00      03:00
    
    
     2. 06:59 UTC - 07:04 UTC
    
               [_______________]
         ----------|xxxxxxxxxx|----------
     Local        02:00      03:00
    

    Note that the relative lookup window is expanded to cover the “missing” hour, ensuring no transitions are missed.

     Fall Backwards (negative delta)
     Occurs on 11/1/2020 06:00 UTC
    
    
     1. 05:54 UTC - 05:59 UTC
    
                         [____]
         ----------|==========|----------
     Local        01:00      02:00
    
    
     2. 05:59 UTC - 06:04 UTC
    
                   |__]     [_|
         ----------|==========|----------
     Local        01:00      02:00
    
    
     3. 06:59 UTC - 07:04 UTC
    
                             [____]
         ----------|==========|----------
     Local        01:00      02:00
    

    To ensure that we don’t change location modes past 2AM too early, as we hit 2AM the first time, we split the lookup window, apply the DST offset delta to the second half, and thus re-process the past hour.

  4. Hooked up a worker to process latitude and longitude location changes and automatically set the time zone. This setting served as the base, and could be overwritten later by a user if desired.

  5. Treated errors and edge cases as GMT. If a lat / long pair isn’t valid, or the translation service can’t be contacted, the location’s timezone is assumed to be GMT with no DST.

  6. Covered all the new logic with testing, particularly unit tests around conversion of the absolute UTC window to each timezone’s local window and ensuring scheduled changes were dispatched correctly.

Internally, everything time-related was operated on and stored in UTC. Times were only converted to timezone-aware at the last possible second, making testing and debugging much easier.

The Forever Summer Bug

For most users, everything worked great. With a little bit of monitoring (and follow-up during daylight savings transitions during the next year), it became clear that the changes worked. However, on the mobile side, one group of users was still having trouble. We quickly identified a chain of commonalities:

  • Transitions were occuring at the expected time, according to the database.
  • All affected locations were in BST.
  • The app displayed schedule night times for these locations an hour later than when they were actually occurring (later, we learned that this wasn’t entirely true).

It was clear, then, that something was going on on the application side. By sheer luck, I had been recently read a number of articles around total removal of daylight savings time, one of which mentioned historic periods of deviation, including BST:

A further inquiry during 1966–1967 led the government of Harold Wilson to introduce the British Standard Time experiment, with Britain remaining on GMT+1 throughout the year. This took place between 27 October 1968 and 31 October 1971, when there was a reversion to the previous arrangement.

https://en.wikipedia.org/wiki/British_Summer_Time#Periods_of_deviation

This seemed suspicious, but why would a period almost 50 years ago be related? Then a thought occurred: there was one other, commonly-used, related concept that was sitting smack the middle of that range: the unix epoch. January 1, 1970. Could that be part of this? From there, everything unlocked:

  1. The user would be presented with a start and end wheel to select hours and minutes (without dates).

  2. User confirms the new times.

  3. To convert the times to UTC, the app:

    1. Added the time to the unix epoch time, creating a naive datetime like 1970-01-01 23:00:00.
    2. Given a timezone, applied the non-DST version of it, if one exists.
    3. Converted the times to UTC.
    4. Posted the new schedule to our API.

Finding the base timezone offset using a time added to the epoch turned out to be where things went all wrong. As it turns out, many time libraries have historic timezone data. When finding the base timezone offset in #3b, because BST+1 was in effect year round, the time sent to the server was always an hour off.

The solution was easy, of course: build a stamp using the desired time and the current date, not the epoch, and find the base timezone from there.