Recently I had to fix a bug where a DateFormatter would return nil when trying to convert a String to a Date. The date string was in a perfectly valid format and the formatter would return nil while on two dates when all the other dates could be transformed without problem.
Here is a screenshot of a playground that I used to examine the problem:
As you can see the formatter does not have a problem with 2022-03-27T01:30:00 but for some reason it cannot transform 2022-03-27T02:00:00 to a valid date
I observed that this invalid dates only occur on March 27th between 02:00a.m. and 02:59a.m.
And then it dawned on me. Daylight Saving Time! Of course. In my country we change our clocks on March 27th directly from 01:59 to 03:00. So the formatter is correctly returning nil when trying to format a date that lies in the hour that does not exist during that night.
So a DateFormatter is quite clever and knows about the oddities of DST. Turns out, that DateFormatter is even more clever when you want it to be: By setting its isLenient property to true the formatter uses heuristics to guess what date you wanted when you provides the date string for a date that does not exist.
Here is what happens when you let the DateFormatter guess what you really wanted:
As you can see now the formatter correctly transforms the 02:00a.m. date to 03:00.a.m. during the night of the switch to Daylight Saving Time.
This might not always be what you want, but in my case it was the (easy) fix to the bug.
My new year started with a really strange bug. I use a DateFormatter to show the current month to the user. So when I opened the app on January 02, 2021 I expected to see “Saturday, Jan 2, 2021”. But what I got was “Saturday, Jan 2, 2020”. Somehow my DateFormatter did not get the message that 2020 was finally over.
let isoDateFormatter = ISO8601DateFormatter()
let january2 = isoDateFormatter.date(from:"2021-01-02T12:00:00+0000")!
let fmt = DateFormatter()
fmt.dateFormat = "EEEE, MMM d, YYYY"
fmt.locale = Locale(identifier: "en_GB")
fmt.string(from: january2) // gives you "Saturday, Jan 2, 2020"
So what happened? It took me quite a while to get my head around this bug. We all know that timezones can be tricky, but my DateFormatter did get the weekday, day and month correctly but the year was wrong. So it was not a matter of timezone confusion.
After fiddeling around a bit I replaced “YYYY” with “yyyy” and the bug was fixed.
fmt.dateFormat = "EEEE, MMM d, yyyy"
fmt2.string(from: january2) // gives you "Saturday, Jan 2, 2021"
But why? Turns out that “YYYY” uses a week-based calendar for its calculations.
So what is a week-based calendar???
A week-based calendar uses calendar weeks for its calculations. The concept of a calendar week is pretty straight forward. A year usually has 52 weeks so you can identify any week in a year by its number (e.g. week 17 is usually the last week in April).
But when does the first calendar week start? January 1st? That would be easy. Unfortunately it is more complicated than that. I found this definition:
In Europe, the first calendar week of the year is the first week that contains four days of the new year.
For 2021 this means that the first calendar week starts on Monday, Jan 4, because the first 3 days in January are in the previous week. And that week only contains 3 days of the new year and is therefor considered the last calendar week of 2020.
So, in a week-based calendar the first three days of 2021 are still part of 2020. Crazy.
But that’s not the end of the craziness. You might have wondered about the “In Europe” part of the definition”. The thing is: In Europe the first day of a week is Monday. In other countries (like the US and Canada) it is Sunday and in the Middle East it is Saturday. To add even more confusion: Some countries (like the US and Canada) choose to make things easier and start the first calendar week on January 1, no matter which day of the week this happens to be.
Here is a comparison between calendars in the USA and the UK (have a look at the week numbers and the first day of the week):
So the same day can be in different calendar weeks, depending on your locale:
let fmtGB = DateFormatter()
fmtGB.dateFormat = "YYYY-ww-D"
fmtGB.locale = Locale(identifier: "en_GB")
let fmtUS = DateFormatter()
fmtUS.dateFormat = "YYYY-ww-D"
fmtUS.locale = Locale(identifier: "en_US")
fmtGB.string(from: january2) // gives you "2020-53-2" (day 2 in week 53 of 2020)
fmtUS.string(from: january2) // gives you "2021-01-2" (day 2 in week 1 of 2021)
So in the future I will be really careful NOT to use “YYYY” in a DateFormatter. The worst thing about this bug is that it only occurs in the first days of a new year (or the last days of the old year). The rest of the year everything is working well. Quite a sneaky bug that is 😉