This post is based on a lightning talk I gave at CocoaConf San Jose a couple of days ago.
It’s that time of year– the time when summer time, or “Daylight Saving Time” as we for some reason call it in the USA– is ending. That time when a developer’s thoughts turn to date math and what a pain in the ass it can be.
Why is this so hard to get right? It’d be nice to think that dates and times follow predictable routines whose cycles all use nice round (or at least consistent) numbers. But consider: What’s the weather on Sunday?
However:
-
The universe doesn’t care about regular numbers. The length of a year doesn’t divide into an integer number of days. Convenient time periods like months are no better. The Earth’s rotation isn’t entirely consistent. So we have leap years, and leap seconds. Months have variable lengths.
-
Living on a planet which rotates on an axis that doesn’t point toward the sun means that time zones– or something like them– are inevitable. There have been various ideas about using a single global time zone, but that just ends up reinventing zones in a new guise (instead of tracking what time it is in different places, you end up needing to know what time the day starts in different places). Sadly the rules of time zones are in the hands of literally every government in the world. They can change at any time for reasons that don’t have to make sense.
-
Times and dates are extremely familiar concepts. Anyone who uses a calendar and/or a clock of some sort tends to see them as mundane, routine things. That leads to complacency when writing code, and bugs inevitably result. Developers take date math for granted because it seems simple. Next thing you know you’re looking at an app that shows two Sundays in a week and nobody’s quite sure why.
Doing all of this correctly often means using the iOS frameworks instead of mistakenly thinking you know what you’re doing. That hands the problem to people who do this kind of thing all the time and who are a lot less likely than you to screw it up.
[NS]Calendar
is your friend and will gladly help you out here. In iOS 8 it gained a bunch of useful new methods, too, so I recommend looking closely at the docs. But it’s not always so simple as finding the right framework method. In the rest of this post I’ll go through some common problems and how to avoid them.
When is tomorrow?
This is such an obvious question that it’s a pity so many developers get it wrong. The question is usually phrased as something like, how do I get a date that’s the same time tomorrow as it is right now?
// WRONG
let now = Date()
let tomorrow = now.addingTimeInterval(24*60*60)
Get the time 24 hours from now! Or maybe 23. Or 25. Or maybe 24 hours and one second. This is a fun one because it will very often give the correct answer. If you used this code most days, it’d be correct. But if you had used it on Saturday in the USA it would be off by an hour– because daylight saving time ended that night. This code is bad unless you actually want exactly 24 hours without considering time changes.
In this case [NS]Calendar
has you covered, with this handy method for doing date math:
let tomorrow = Calendar.current.date(byAdding:.day,
value:1, to:now)
What is today?
It sounds like a trivial concept. Maybe your app needs to find events that happen “today”. And you can get that by looking at the time from midnight to midnight. That’s today, right?
Right?
// Wrongy McWrongface
let startOfDay = Calendar.current.date(bySettingHour:0,
minute:0, second:0, of:now)
let startOfTomorrow = startOfDay.addingTimeInterval(24*60*60)
The first line takes today
and gets a new date with the hour, minute, and second set to 0 to get the most recent midnight. The next line adds 24 hours, because we’re already being sloppy so why not?
This one’s even better than the last. In the USA we’re used to the idea that summer time starts at 2 AM. But in some time zones it starts at midnight. One second it’s 11:59:59 and the next it’s 01:00:00 with no midnight. The code is fine, usually, but will have weird bugs that come up only occasionally and only in some countries. Oops.
The code above uses [NS]Calendar
but still makes bogus assumptions. A better approach would be:
let startOfDay = Calendar.current.startOfDay(for:now)
let startOfTomorrow = Calendar.current.date(byAdding:.day,
value:1, to:startOfDay)
If you’re looking for when “today” started, ask for when today started. Don’t assume you know. While you’re at it, don’t forget that “today” is a concept that depends on the local time zone. Today for you is different than today for someone a couple thousand miles east or west of you.
Artisanal Locally-Grown Time
So how do you deal with time zones, anyway?
First, of course, don’t ever save local time. Save times and dates as UTC. Convert to and from local time when needed. When presenting dates to a user, use [NS]DateFormatter
to convert the UTC date to an appropriate time zone. If your app lets the user enter times and/or dates, convert that to UTC before storing it. Whatever you do, do not attempt to do your own time zone conversions.
True story: I once worked on an app where all time zone support was handled with the following values, which were hard coded in the app:
Time Zone | UTC offset |
---|---|
Eastern | -18000s |
Central | -21600s |
Mountain | -25200s |
Pacific | -28800s |
Those are the four best-known time zones in North America with the GMT offsets for winter. Oh geez, I thought. It’s bad enough that this doesn’t account for moving clocks forward and back like we do. But it also misses the fact that Arizona doesn’t mess with daylight “saving” time. It’s correct maybe 45% of the time, if I’m generous and assume that the app won’t ever be used outside of the continental USA.
True fact: there are several hundred time zones in the world. A time zone combines an offset from UTC with rules about how that offset changes based on the date. Most time zones have summer time, but not all. Those that do have it usually disagree about when it starts, or ends, or both. Any difference means you have a different time zone.
Fortunately iOS encapsulates all this in [NS]TimeZone
. It uses the IANA Time Zone Database which is a de facto standard used by pretty much every computer company everywhere.
If you need to calculate dates in a different time zone, use [NS]Calendar
and tell it what time zone to use. So if you need to know when “today” is somewhere else, just choose the right time zone.
var myCalendar = Calendar.current
if let timeZone = TimeZone(identifier: "America/New_York") {
myCalendar.timeZone = timeZone
}
let startOfDay = myCalendar.startOfDay(for: now)
How do I convert a date to a different time zone?
You don’t, if by “date” you mean [NS]Date
. It represents a single instant in time, everywhere (ignoring relativistic effects). That means if you use the following and someone on a different continent does so at the same moment, you both get the same result!
Don’t forget: a Date
has no time zone information. Also, don’t forget: A Date
has no time zone information. I realize that technically those are the same thing, but it’s such a common mistake I thought it was worth mentioning twice.
Since there’s no time zone information, converting to a different time zone doesn’t make sense. If you think you need to convert a date to a different time zone, you’re probably already in deep trouble with dates. It’s time to stop what you’re doing and reconsider your life choices.
How long is a month?
This one’s more obvious than the others because of course you know that different months have different lengths. But remember, it’s all edge cases. What if you want to get the date one month from today? What if “today” happens to be January 31? February 31 maybe?
If you’re asking that question on the last day of the month, you probably want the last day of next month. You can use that handy date(byAdding:, value:, to:)
method from earlier here, but you have to be careful how you do it.
One way is a cumulative calculation, adding one month to the start date, and then one month to that, etc:
// Get a date on 31 January 2017
let startDate = (Calendar.current as NSCalendar).date(era: 1, year: 2017, month: 1, day: 31, hour: 12, minute: 0, second: 0, nanosecond: 0)
print(startDate)
var currentDate : Date = startDate!
for i in 1...12 {
currentDate = Calendar.current.date(byAdding: .month, value: 1, to: currentDate)!
print(currentDate)
}
Things start out OK, but quickly go awry:
2017-01-31 19:00:00 +0000
2017-02-28 19:00:00 +0000
2017-03-28 18:00:00 +0000
2017-04-28 18:00:00 +0000
2017-05-28 18:00:00 +0000
2017-06-28 18:00:00 +0000
2017-07-28 18:00:00 +0000
2017-08-28 18:00:00 +0000
2017-09-28 18:00:00 +0000
2017-10-28 18:00:00 +0000
2017-11-28 19:00:00 +0000
2017-12-28 19:00:00 +0000
January 31, then February 28? Good. But one month after that is March 28. You get stuck on the 28th instead of the end of the month.
Another approach is to keep adding months to the start date, discarding intermediate values when you don’t need them anymore:
for i in 1...12 {
let nextDate = Calendar.current.date(byAdding: .month, value: i, to: startDate!)
print(nextDate!)
}
This looks better:
2017-01-31 19:00:00 +0000
2017-02-28 19:00:00 +0000
2017-03-31 18:00:00 +0000
2017-04-30 18:00:00 +0000
2017-05-31 18:00:00 +0000
2017-06-30 18:00:00 +0000
2017-07-31 18:00:00 +0000
2017-08-31 18:00:00 +0000
2017-09-30 18:00:00 +0000
2017-10-31 18:00:00 +0000
2017-11-30 19:00:00 +0000
2017-12-31 19:00:00 +0000
There we go, last day of the month every time. Get up and do a happy dance, you’ve earned it.
What can I do?
![](Entering Mountain.jpg)
When it comes to dates, everything you know is wrong. At least sometimes. Code that looks like it works may well have weird bugs that only manifest on specific dates or in specific locations.
So in short: assume nothing. Even the most obvious things, like when today started, are likely wrong at some point. Use the iOS frameworks whenever possible.
But even then, test your results! “I used a framework method” doesn’t mean you actually did the right thing.
Good luck, we’ll all need it.