Calendar nextDate/enumerateDates provides wrong dates

I’m trying to get previous dates that matches specific months in the year. It mainly works but for an unknown reason, it does not work as expected for the 9nth month in the Gregorian calendar (which is September). When the after date is Date.now (currently in December), I expect the previous September month to be in 2023. But the first date returned is in 1995.

var calendar: Calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone.autoupdatingCurrent

let matchingDateComponents: DateComponents = DateComponents(month: 09)
                        
let date: Date? = calendar.nextDate(
    after: Date.now,
    matching: matchingDateComponents,
    matchingPolicy: .nextTime,
    direction: .backward
)

I tested with other time zones and the results are different... For some time zones, the result is correct, for others, it's not. Why? Do you have any idea @eskimo @davedelong?

  • Optional(1970-08-31 23:00:00 +0000) for Europe/London
  • Optional(1995-08-31 23:00:00 +0000) for Europe/Paris
  • Optional(2002-08-31 22:00:00 +0000) for Europe/Vilnius
  • Optional(2023-09-01 07:00:00 +0000) for America/Los_Angeles

I used the following code to print the result for all time zones:

for zone in TimeZone.knownTimeZoneIdentifiers {
    var calendar: Calendar = Calendar(identifier: .gregorian)
    calendar.timeZone = TimeZone(identifier: zone) ?? .autoupdatingCurrent
                
    let matchingDateComponents: DateComponents = DateComponents(month: 9)
                
    let date: Date? = calendar.nextDate(
        after: Date.now,
        matching: matchingDateComponents,
        matchingPolicy: .nextTime,
        direction: .backward
    )
                
    print(date, zone)
}

I filed a feedback for that: FB13462533

When searching .forward, and only specifying a month in the date components, we expect to get the next start of the month. So if I request the nextDate that matches December after December the 12th in 2023, I expect to get December the 1st in 2024. This is the actual result provided by the nextDate method.

On the contrary, when we search .backward, and we only specify a month, I expect to get the last day of the specified month, not the first one. To me, the next date that matches only a month when searching backward is the last day (first day encountering the specified month if we go back day by day). Maybe this is for another topic.

Anyway, let's assume I'm wrong and the expected date is still the start of the month. I tested with other date components to see if the dates provided by these methods are correct when searching backward. I tested using a .nextTime and .strict matchingPolicy.

DateComponents(month: 1)
Correct years, wrong days. I mainly get dates around January the 5th. But the years seem to be correct. In this case, only the America/Los_Angeles date seems to be correct. Even GMT is wrong.

  • 2023-01-05 00:00:00 +0000 for Europe/London
  • 2023-01-04 23:00:00 +0000 for Europe/Paris
  • 2023-01-04 22:00:00 +0000 for Europe/Vilnius
  • 2023-01-01 08:00:00 +0000 for America/Los_Angeles
  • 2023-01-05 00:00:00 +0000 for GMT

DateComponents(month: 2)
Correct years, correct days

DateComponents(month: 3)
Correct years, wrong days

DateComponents(month: 4)
Correct years, correct days

DateComponents(month: 5)
Correct years, wrong days. I mainly get dates around May the 3rd.

  • 2023-05-02 23:00:00 +0000 for Europe/London
  • 2023-05-02 22:00:00 +0000 for Europe/Paris
  • 2023-05-02 21:00:00 +0000 for Europe/Vilnius
  • 2023-05-03 07:00:00 +0000 for America/Los_Angeles
  • 2023-05-03 00:00:00 +0000 for GMT

DateComponents(month: 6)
Correct years, correct days

DateComponents(month: 7)
Correct years, wrong days

DateComponents(month: 8)
Correct years, wrong days

DateComponents(month: 9)
Wrong years, correct days

DateComponents(month: 10)
Correct years, wrong days

DateComponents(month: 11)
Correct years, correct days

DateComponents(month: 12)
Correct years, correct days if we expect to get the start of the December from the previous year when we already are in December (and not the start of the current month).

If I additionally specify a day in the date components like DateComponents(month: 1, day: 1) or DateComponents(month: 4, day: 20), I get the correct results for all time zones, except for September (still wrong years).

As Foundation is Open Source, I opened an issue on GitHub: Calendar nextDate/enumerateDates provides wrong dates when using backward search direction · Issue #348 · apple/swift-foundation · GitHub

I briefly looked at this last night after out interaction on Mastodon, and what stood out to me was that this was consistently happening for specific time zones. Perhaps something is off about those time zone definitions that is leading to unexpected results.

The timezones where I observed this happening
Africa/Cairo
Africa/Ceuta
America/Santiago
America/Scoresbysund
Antarctica/McMurdo
Antarctica/South_Pole
Antarctica/Troll
Arctic/Longyearbyen
Asia/Beirut
Asia/Famagusta
Asia/Gaza
Asia/Hebron
Asia/Jerusalem
Asia/Nicosia
Atlantic/Azores
Atlantic/Canary
Atlantic/Faroe
Atlantic/Madeira
Europe/Amsterdam
Europe/Andorra
Europe/Athens
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/Jersey
Europe/Kiev
Europe/Kyiv
Europe/Lisbon
Europe/Ljubljana
Europe/London
Europe/Luxembourg
Europe/Madrid
Europe/Malta
Europe/Mariehamn
Europe/Monaco
Europe/Oslo
Europe/Paris
Europe/Podgorica
Europe/Prague
Europe/Riga
Europe/Rome
Europe/San_Marino
Europe/Sarajevo
Europe/Skopje
Europe/Sofia
Europe/Stockholm
Europe/Tallinn
Europe/Tirane
Europe/Uzhgorod
Europe/Vaduz
Europe/Vatican
Europe/Vienna
Europe/Vilnius
Europe/Warsaw
Europe/Zagreb
Europe/Zaporozhye
Europe/Zurich
Pacific/Auckland
Pacific/Chatham
Pacific/Easter
My test code
for tz in TimeZone.knownTimeZoneIdentifiers {
    
    var cal = Calendar(identifier: .gregorian)
    cal.timeZone = TimeZone(identifier: tz)!
    
    let match = DateComponents(month: 9)
    
    // I tried .strict, .nextTime, etc
    let date = cal.nextDate(after: .now, matching: match, matchingPolicy: .strict, direction: .backward)!
    
    let comps = cal.dateComponents(in: cal.timeZone, from: date)
    if comps.year != 2023 {
        let desc = DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .long)
        print(tz, "->", desc)
    }
    
}

Thanks @davedelong for providing the test code and the list of time zones that encounters the off behaviour. I'm not sure what to do know, as it seems to be a bug in the framework (either in the Calendar functions or the Timezone definition).

Also, I don't understand why for some months, the provided date is not the first day of the month

  • January: 2023-01-05 for GMT
  • March: 2023-03-03 for GMT
  • May: 2023-05-03 for GMT
  • July: 2023-07-02 for GMT
  • August: 2023-08-03 for GMT
  • October: 2023-10-03for GMT

It seems to be months with 31 days but it's working as expected for December with 2022-12-31 23:59:59 for GMT.

autoupdating time zone seems to be the culprit. Try this workaround:

calendar.timeZone =
    TimeZone(secondsFromGMT: TimeZone.autoupdatingCurrent.secondsFromGMT())!

That would be my expectation as well (last day, last hour/minute/second of the last day). Looks very non-intuitive otherwise.

Thanks @tera for the suggestion. Unfortunately, if I fix the time zone offset, the dates will all be wrong because the time zone specificities won't be used to calculate next dates (like Daylight Saving Time for example).

At least it's not as wrong as "1970" :wink:
Another option – do the calendar calculation yourself until the bug is fixed.

I think this bug has to do with when daylight saving time is or is not observed in various time zones. The calculation works by finding the range of the month of the given date, then setting the date back 1 day, then finding the range of that month, and so on. So for Europe/London and a start date in December, we are getting ranges like this:

  • 2023-12-01 00:00:00 +0000 to 2024-01-01 00:00:00 +0000
  • 2023-11-01 00:00:00 +0000 to 2023-12-01 00:00:00 +0000
  • 2023-09-30 23:00:00 +0000 to 2023-11-01 00:00:00 +0000 - daylight saving was here, I think - and this varies by time zone, which explains why this only occurs for some
  • 2023-07-31 23:00:00 +0000 to 2023-08-31 23:00:00 +0000 - the bug - we skipped a month

With respect to this part specifically, the behavior is actually defined to find the start of the range. I'm not actually the person who made this decision, but I think it makes sense when combined with the range API to get the endpoint.

Here's an example outside of this buggy month calculation:

let c = Calendar.current
print(Date.now)
print(c.nextDate(after: .now, matching: DateComponents(minute: 10), matchingPolicy: .nextTime)!)
print(c.nextDate(after: .now, matching: DateComponents(minute: 10), matchingPolicy: .nextTime, direction: .backward)!)
2023-12-19 00:48:05 +0000
2023-12-19 01:10:00 +0000 // next minute 10
2023-12-19 00:10:00 +0000 // previous minute 10, but nb. at the start of minute 10