Dealing With Dates With No Time in Swift
Where the Streets Dates Have No Name Time
Where the Streets Have No Name, the U2 song, was released on 31 August 1987.
At what time? It is not relevant. Just the date, ie the calendar day, matters.
If I had to translate this in a JSON API, I would write the following:
{"release_date": "1987-08-31"}
, ie basic ISO 8601, no time or time zone.
When dealing with such dates with no time, some operations can be necessary. For instance, what is the 7th day after 31 August 1987? No dependency allowed. This blog post will answer this question. Easy peasy, right?
But beware, the experienced developer should be humble when coding such operations. Dealing with calendars is hard and I can easily prove it. Just answer this simple question: is every minute in a calendar 60 seconds long? If you answered “yes”, you failed the test. Go study and have fun.
Hopefully, Swift’s Foundation
provides everything we need to avoid obvious
traps. Let’s see what a struct to handle dates with no time could look like.
The implementation
This is the end result. A discussion follows.
import Foundation
/// A string that represents dates using their ISO 8601 representations.
///
/// `PlainDate` is a way to handle dates with no time — such as `2022-03-02` for March 2nd of 2022 — to
/// perform operations with convenience including adding days, dealing with ranges, etc.
///
/// ## Usage Overview
///
/// A plain date can be initiated from a string literal, and can be used to create ranges.
///
/// let plainDate: PlainDate = "2022-03-01"
/// let aWeekLater = plainDate.advanced(by: 7)
/// for day in march1st ..< aWeekLater {
/// print(day)
/// }
public struct PlainDate {
// MARK: - Creating an instance
/// Returns a date string initialized using their ISO 8601 representation.
/// - Parameters:
/// - dateAsString: The ISO 8601 representation of the date. For instance, `2022-03-02`for March 2nd of 2022.
/// - calendar: The calendar — including the time zone — to use. The default is the current calendar.
/// - Returns: A date string, or `nil` if a valid date could not be created from `dateAsString`.
public init?(from dateAsString: String, calendar: Calendar = .current) {
let formatter = Self.createFormatter(timeZone: calendar.timeZone)
guard let date = formatter.date(from: dateAsString) else {
return nil
}
self.init(date: date, calendar: calendar, formatter: formatter)
}
/// Returns a date string initialized using their ISO 8601 representation.
/// - Parameters:
/// - date: The date to represent.
/// - calendar: The calendar — including the time zone — to use. The default is the current calendar.
public init(date: Date, calendar: Calendar = .current) {
self.init(date: date, calendar: calendar, formatter: Self.createFormatter(timeZone: calendar.timeZone))
}
private init(date: Date, calendar: Calendar = .current, formatter: ISO8601DateFormatter) {
self.formatter = formatter
self.date = date
self.calendar = calendar
}
// MARK: - Properties
private let formatter: ISO8601DateFormatter
private let date: Date
private let calendar: Calendar
private static func createFormatter(timeZone: TimeZone) -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate]
formatter.timeZone = timeZone
return formatter
}
}
extension PlainDate: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self.init(from: value)!
}
}
extension PlainDate: Strideable {
public func distance(to other: PlainDate) -> Int {
let timeInterval = date.distance(to: other.date)
return Int(round(timeInterval / 86400.0))
}
public func advanced(by value: Int) -> PlainDate {
let newDate = calendar.date(byAdding: .day, value: value, to: date)!
return PlainDate(date: newDate, calendar: calendar, formatter: formatter)
}
}
Just how bad is DateString for a name?
Edit: the first name for this class was DateString
. I know. Since, I found out
about the Temporal
proposal for JavaScript that introduces something very
similar to what I am trying to achieve here as PlainDate
. So I renamed this
accordingly.
Why are time zones involved in this code?
It’s the result of logical decisions I made from the tools I had at hand:
- Converting an ISO 8601 string? Use
ISO8601DateFormatter
! - What type does
ISO8601DateFormatter
convert the inputString
into? As aDate
! - So I should use a
Date
— with time — to store a date with no time? Yes! - How can I output the date back into a
String
? UseISO8601DateFormatter
again! - But look at this test, it’s getting buggy around days on which clocks change to deal with daylight saving time? Use a time zone!
- So I have to carry a
Calendar
and aTimeZone
around? No need: aCalendar
does include aTimeZone
, just carry theCalendar
around!
Implementing ExpressibleByStringLiteral is so easy and convenient!
Yes, it is. Expressing a PlainDate
just as "1987-08-31"
is indeed pretty
awesome.
Wow, and what about implementing Strideable?
I know, right? On top of answering our original question
(date.advanced(by: offset)
), Strideable
helps doing things like:
let startDate: PlainDate = "2022-03-01"
let aWeekLater = startDate.advanced(by: 7)
for date in startDate ..< aWeekLater {
// do something.
}
Wait a minute! Is this a hard-coded 86400 in the code? Isn’t that forbidden?
🕵️ Good eye. 86400 is the number of seconds in a typical day. But not all days are 86400 seconds long. But the point here is to compute the distance in days between two dates, and I think this implementation is OK. I’ll explain why:
Calendar
is ignoring leap seconds. I know because a former Apple Foundation employee told me so.- See the
round
? This is enough to deal with daylight savings according to my tests and my understanding of how daylight savings work. Consider a collection that contains the number of seconds of an arbitrary number of consecutive days. (a) There’s more than a 99% chance that a randomly picked item is 86400. (b) Considering that the 2 possible outliers values will alternate: if you find a 23-hour long day (82800 seconds) in the collection, you will find a 25-hour long day (90000 seconds) before you find another occurrence of a 23-hour long day. So the average of a set will tend to 86400 as it gets bigger. - Here is a more intellectually satisfying alternative for this computation. It might be more solid if you’re working on something really time-sensitive. But it is about 2 times slower. So as long as my fast implementation does not fail me, I’ll stick to it.
let start = calendar.ordinality(of: .day, in: .era, for: self.date)
let end = calendar.ordinality(of: .day, in: .era, for: other.date)
guard let start = start, let end = end else {
fatalError("The distance between 2 dates could not be computed.")
}
return end - start
Conclusion
Mission accomplished. Feel free to use this PlainDate
class as is. I’ve been
using it for a hobby project — a soccer newsletter — and it works great. If you
can read French and soccer is your thing, you can sign up for the
newsletter.
Addendum — iOS & macOS Time Zones
Here is a table of all the known time zones’ identifiers and abbreviations as run on iOS 15.2 and macOS 12.2.1.
For some reason, UTC
is an abbreviation to the UTC
identifier, but UTC
is
missing in the list of known identifiers. 🤷♂️ I initalized a time zone using
UTC
: it succeeded and querying the identifier returned GMT
. Double-🤷♂️.
Identifier | Abbreviations |
---|---|
Africa/Abidjan | |
Africa/Accra | |
Africa/Addis_Ababa | EAT |
Africa/Algiers | |
Africa/Asmara | |
Africa/Bamako | |
Africa/Bangui | |
Africa/Banjul | |
Africa/Bissau | |
Africa/Blantyre | |
Africa/Brazzaville | |
Africa/Bujumbura | |
Africa/Cairo | |
Africa/Casablanca | |
Africa/Ceuta | |
Africa/Conakry | |
Africa/Dakar | |
Africa/Dar_es_Salaam | |
Africa/Djibouti | |
Africa/Douala | |
Africa/El_Aaiun | |
Africa/Freetown | |
Africa/Gaborone | |
Africa/Harare | CAT |
Africa/Johannesburg | |
Africa/Juba | |
Africa/Kampala | |
Africa/Khartoum | |
Africa/Kigali | |
Africa/Kinshasa | |
Africa/Lagos | WAT |
Africa/Libreville | |
Africa/Lome | |
Africa/Luanda | |
Africa/Lubumbashi | |
Africa/Lusaka | |
Africa/Malabo | |
Africa/Maputo | |
Africa/Maseru | |
Africa/Mbabane | |
Africa/Mogadishu | |
Africa/Monrovia | |
Africa/Nairobi | |
Africa/Ndjamena | |
Africa/Niamey | |
Africa/Nouakchott | |
Africa/Ouagadougou | |
Africa/Porto-Novo | |
Africa/Sao_Tome | |
Africa/Tripoli | |
Africa/Tunis | |
Africa/Windhoek | |
America/Adak | |
America/Anchorage | |
America/Anguilla | |
America/Antigua | |
America/Araguaina | |
America/Argentina/Buenos_Aires | ART |
America/Argentina/Catamarca | |
America/Argentina/Cordoba | |
America/Argentina/Jujuy | |
America/Argentina/La_Rioja | |
America/Argentina/Mendoza | |
America/Argentina/Rio_Gallegos | |
America/Argentina/Salta | |
America/Argentina/San_Juan | |
America/Argentina/San_Luis | |
America/Argentina/Tucuman | |
America/Argentina/Ushuaia | |
America/Aruba | |
America/Asuncion | |
America/Atikokan | |
America/Bahia | |
America/Bahia_Banderas | |
America/Barbados | |
America/Belem | |
America/Belize | |
America/Blanc-Sablon | |
America/Boa_Vista | |
America/Bogota | COT |
America/Boise | |
America/Cambridge_Bay | |
America/Campo_Grande | |
America/Cancun | |
America/Caracas | |
America/Cayenne | |
America/Cayman | |
America/Chicago | CDT, CST |
America/Chihuahua | |
America/Costa_Rica | |
America/Creston | |
America/Cuiaba | |
America/Curacao | |
America/Danmarkshavn | |
America/Dawson | |
America/Dawson_Creek | |
America/Denver | MDT |
America/Detroit | |
America/Dominica | |
America/Edmonton | |
America/Eirunepe | |
America/El_Salvador | |
America/Fort_Nelson | |
America/Fortaleza | |
America/Glace_Bay | |
America/Godthab | |
America/Goose_Bay | |
America/Grand_Turk | |
America/Grenada | |
America/Guadeloupe | |
America/Guatemala | |
America/Guayaquil | |
America/Guyana | |
America/Halifax | ADT, AST |
America/Havana | |
America/Hermosillo | |
America/Indiana/Indianapolis | |
America/Indiana/Knox | |
America/Indiana/Marengo | |
America/Indiana/Petersburg | |
America/Indiana/Tell_City | |
America/Indiana/Vevay | |
America/Indiana/Vincennes | |
America/Indiana/Winamac | |
America/Inuvik | |
America/Iqaluit | |
America/Jamaica | |
America/Juneau | AKDT, AKST |
America/Kentucky/Louisville | |
America/Kentucky/Monticello | |
America/Kralendijk | |
America/La_Paz | |
America/Lima | PET |
America/Los_Angeles | PDT, PST |
America/Lower_Princes | |
America/Maceio | |
America/Managua | |
America/Manaus | |
America/Marigot | |
America/Martinique | |
America/Matamoros | |
America/Mazatlan | |
America/Menominee | |
America/Merida | |
America/Metlakatla | |
America/Mexico_City | |
America/Miquelon | |
America/Moncton | |
America/Monterrey | |
America/Montevideo | |
America/Montreal | |
America/Montserrat | |
America/Nassau | |
America/New_York | EDT, EST |
America/Nipigon | |
America/Nome | |
America/Noronha | |
America/North_Dakota/Beulah | |
America/North_Dakota/Center | |
America/North_Dakota/New_Salem | |
America/Nuuk | |
America/Ojinaga | |
America/Panama | |
America/Pangnirtung | |
America/Paramaribo | |
America/Phoenix | MST |
America/Port-au-Prince | |
America/Port_of_Spain | |
America/Porto_Velho | |
America/Puerto_Rico | |
America/Punta_Arenas | |
America/Rainy_River | |
America/Rankin_Inlet | |
America/Recife | |
America/Regina | |
America/Resolute | |
America/Rio_Branco | |
America/Santa_Isabel | |
America/Santarem | |
America/Santiago | CLST, CLT |
America/Santo_Domingo | |
America/Sao_Paulo | BRST, BRT |
America/Scoresbysund | |
America/Shiprock | |
America/Sitka | |
America/St_Barthelemy | |
America/St_Johns | NDT, NST |
America/St_Kitts | |
America/St_Lucia | |
America/St_Thomas | |
America/St_Vincent | |
America/Swift_Current | |
America/Tegucigalpa | |
America/Thule | |
America/Thunder_Bay | |
America/Tijuana | |
America/Toronto | |
America/Tortola | |
America/Vancouver | |
America/Whitehorse | |
America/Winnipeg | |
America/Yakutat | |
America/Yellowknife | |
Antarctica/Casey | |
Antarctica/Davis | |
Antarctica/DumontDUrville | |
Antarctica/Macquarie | |
Antarctica/Mawson | |
Antarctica/McMurdo | |
Antarctica/Palmer | |
Antarctica/Rothera | |
Antarctica/South_Pole | |
Antarctica/Syowa | |
Antarctica/Troll | |
Antarctica/Vostok | |
Arctic/Longyearbyen | |
Asia/Aden | |
Asia/Almaty | |
Asia/Amman | |
Asia/Anadyr | |
Asia/Aqtau | |
Asia/Aqtobe | |
Asia/Ashgabat | |
Asia/Atyrau | |
Asia/Baghdad | |
Asia/Bahrain | |
Asia/Baku | |
Asia/Bangkok | ICT |
Asia/Barnaul | |
Asia/Beirut | |
Asia/Bishkek | |
Asia/Brunei | |
Asia/Calcutta | |
Asia/Chita | |
Asia/Choibalsan | |
Asia/Chongqing | |
Asia/Colombo | |
Asia/Damascus | |
Asia/Dhaka | BDT |
Asia/Dili | |
Asia/Dubai | GST |
Asia/Dushanbe | |
Asia/Famagusta | |
Asia/Gaza | |
Asia/Harbin | |
Asia/Hebron | |
Asia/Ho_Chi_Minh | |
Asia/Hong_Kong | HKT |
Asia/Hovd | |
Asia/Irkutsk | |
Asia/Jakarta | WIT |
Asia/Jayapura | |
Asia/Jerusalem | |
Asia/Kabul | |
Asia/Kamchatka | |
Asia/Karachi | PKT |
Asia/Kashgar | |
Asia/Kathmandu | |
Asia/Katmandu | |
Asia/Khandyga | |
Asia/Krasnoyarsk | |
Asia/Kuala_Lumpur | |
Asia/Kuching | |
Asia/Kuwait | |
Asia/Macau | |
Asia/Magadan | |
Asia/Makassar | |
Asia/Manila | PHT |
Asia/Muscat | |
Asia/Nicosia | |
Asia/Novokuznetsk | |
Asia/Novosibirsk | |
Asia/Omsk | |
Asia/Oral | |
Asia/Phnom_Penh | |
Asia/Pontianak | |
Asia/Pyongyang | |
Asia/Qatar | |
Asia/Qostanay | |
Asia/Qyzylorda | |
Asia/Rangoon | |
Asia/Riyadh | |
Asia/Sakhalin | |
Asia/Samarkand | |
Asia/Seoul | KST |
Asia/Shanghai | |
Asia/Singapore | SGT |
Asia/Srednekolymsk | |
Asia/Taipei | |
Asia/Tashkent | |
Asia/Tbilisi | |
Asia/Tehran | IRST |
Asia/Thimphu | |
Asia/Tokyo | JST |
Asia/Tomsk | |
Asia/Ulaanbaatar | |
Asia/Urumqi | |
Asia/Ust-Nera | |
Asia/Vientiane | |
Asia/Vladivostok | |
Asia/Yakutsk | |
Asia/Yangon | |
Asia/Yekaterinburg | |
Asia/Yerevan | |
Atlantic/Azores | |
Atlantic/Bermuda | |
Atlantic/Canary | |
Atlantic/Cape_Verde | |
Atlantic/Faroe | |
Atlantic/Madeira | |
Atlantic/Reykjavik | |
Atlantic/South_Georgia | |
Atlantic/St_Helena | |
Atlantic/Stanley | |
Australia/Adelaide | |
Australia/Brisbane | |
Australia/Broken_Hill | |
Australia/Currie | |
Australia/Darwin | |
Australia/Eucla | |
Australia/Hobart | |
Australia/Lindeman | |
Australia/Lord_Howe | |
Australia/Melbourne | |
Australia/Perth | |
Australia/Sydney | |
Europe/Amsterdam | |
Europe/Andorra | |
Europe/Astrakhan | |
Europe/Athens | EEST, EET |
Europe/Belgrade | |
Europe/Berlin | |
Europe/Bratislava | |
Europe/Brussels | |
Europe/Bucharest | |
Europe/Budapest | |
Europe/Busingen | |
Europe/Chisinau | |
Europe/Copenhagen | |
Europe/Dublin | |
Europe/Gibraltar | |
Europe/Guernsey | |
Europe/Helsinki | |
Europe/Isle_of_Man | |
Europe/Istanbul | TRT |
Europe/Jersey | |
Europe/Kaliningrad | |
Europe/Kiev | |
Europe/Kirov | |
Europe/Lisbon | WEST, WET |
Europe/Ljubljana | |
Europe/London | BST |
Europe/Luxembourg | |
Europe/Madrid | |
Europe/Malta | |
Europe/Mariehamn | |
Europe/Minsk | |
Europe/Monaco | |
Europe/Moscow | MSD, MSK |
Europe/Oslo | |
Europe/Paris | CEST, CET |
Europe/Podgorica | |
Europe/Prague | |
Europe/Riga | |
Europe/Rome | |
Europe/Samara | |
Europe/San_Marino | |
Europe/Sarajevo | |
Europe/Saratov | |
Europe/Simferopol | |
Europe/Skopje | |
Europe/Sofia | |
Europe/Stockholm | |
Europe/Tallinn | |
Europe/Tirane | |
Europe/Ulyanovsk | |
Europe/Uzhgorod | |
Europe/Vaduz | |
Europe/Vatican | |
Europe/Vienna | |
Europe/Vilnius | |
Europe/Volgograd | |
Europe/Warsaw | |
Europe/Zagreb | |
Europe/Zaporozhye | |
Europe/Zurich | |
GMT | GMT |
Indian/Antananarivo | |
Indian/Chagos | |
Indian/Christmas | |
Indian/Cocos | |
Indian/Comoro | |
Indian/Kerguelen | |
Indian/Mahe | |
Indian/Maldives | |
Indian/Mauritius | |
Indian/Mayotte | |
Indian/Reunion | |
Pacific/Apia | |
Pacific/Auckland | NZDT, NZST |
Pacific/Bougainville | |
Pacific/Chatham | |
Pacific/Chuuk | |
Pacific/Easter | |
Pacific/Efate | |
Pacific/Enderbury | |
Pacific/Fakaofo | |
Pacific/Fiji | |
Pacific/Funafuti | |
Pacific/Galapagos | |
Pacific/Gambier | |
Pacific/Guadalcanal | |
Pacific/Guam | |
Pacific/Honolulu | HST |
Pacific/Johnston | |
Pacific/Kanton | |
Pacific/Kiritimati | |
Pacific/Kosrae | |
Pacific/Kwajalein | |
Pacific/Majuro | |
Pacific/Marquesas | |
Pacific/Midway | |
Pacific/Nauru | |
Pacific/Niue | |
Pacific/Norfolk | |
Pacific/Noumea | |
Pacific/Pago_Pago | |
Pacific/Palau | |
Pacific/Pitcairn | |
Pacific/Pohnpei | |
Pacific/Ponape | |
Pacific/Port_Moresby | |
Pacific/Rarotonga | |
Pacific/Saipan | |
Pacific/Tahiti | |
Pacific/Tarawa | |
Pacific/Tongatapu | |
Pacific/Truk | |
Pacific/Wake | |
Pacific/Wallis |
The code to output this table:
import Foundation
for identifier in TimeZone.knownTimeZoneIdentifiers.sorted() {
let abbreviations = TimeZone.abbreviationDictionary.filter { (k, v) in
v == identifier
}
print("| \(identifier) | \(abbreviations.keys.sorted().joined(separator: ", ")) |")
}