DateComponentsFormatter formatted string is not working as expected

Hi!

When using string method of DateComponentsFormatter, it gives sometimes an extra unit(2) in the formatted string instead of one.

In the following snippet, formattedString gives 213d 1h instead of 213d.

let durationShort: DateComponentsFormatter = {     let formatter = DateComponentsFormatter()     formatter.allowedUnits = [.day, .hour, .minute]     formatter.unitsStyle = .abbreviated     formatter.zeroFormattingBehavior = .dropAll     formatter.maximumUnitCount = 3     formatter.collapsesLargestUnit = false     return formatter }()

let formattedString = durationShort.string(from: TimeInterval(86400 * 213))

Any idea?

That's effectively strange at first glance, but there seems to be some logic behind.

It adds 1h at certain numbers of days : over 84, less 301

I tested your code the following values:

let durationShort: DateComponentsFormatter = {
    let formatter = DateComponentsFormatter()
    formatter.allowedUnits = [.day, .hour, .minute]
    formatter.unitsStyle = .abbreviated
    formatter.zeroFormattingBehavior = .dropAll
    formatter.maximumUnitCount = 3
    formatter.collapsesLargestUnit = false
    return formatter }()

let formattedString = durationShort.string(from: TimeInterval(86400 * 213))
print("formattedString", formattedString!)
let formattedString2 = durationShort.string(from: TimeInterval(86400))
print("formattedString2", formattedString2!)
let formattedString3 = durationShort.string(from: TimeInterval(86400 * 21))
print("formattedString3", formattedString3!)
let formattedString4 = durationShort.string(from: TimeInterval(86400 * 30))
print("formattedString4", formattedString4!)
let formattedString5 = durationShort.string(from: TimeInterval(86400 * 35))
print("formattedString5", formattedString5!)
let formattedString6 = durationShort.string(from: TimeInterval(86400 * 83))
print("formattedString6", formattedString6!)
let formattedString7 = durationShort.string(from: TimeInterval(86400 * 84))  // <<-- Adds 1h
print("formattedString7", formattedString7!)
let formattedString8 = durationShort.string(from: TimeInterval(86400 * 120))
print("formattedString8", formattedString8!)
let formattedString9 = durationShort.string(from: TimeInterval(86400 * 300))
print("formattedString9", formattedString9!)
let formattedString10 = durationShort.string(from: TimeInterval(86400 * 301))  // <<-- do not add 1h from here
print("formattedString10", formattedString10!)
let formattedString11 = durationShort.string(from: TimeInterval(86400 * 365))
print("formattedString11", formattedString11!)

I get

  • formattedString 213d 1h
  • formattedString2 1d
  • formattedString3 21d
  • formattedString4 30d
  • formattedString5 35d
  • formattedString6 83d
  • formattedString7 84d 1h // <<-- Adds 1h
  • formattedString8 120d 1h
  • formattedString9 300d 1h
  • formattedString10 301d // <<-- do not add 1h from here
  • formattedString11 365d

It seems to do with daylight saving time

  • In Europe on March 26-27, which is 1/1 + 84d (there is however a 1 day mismatch as day change is on 27 at 2 am. I did not look if GMT could explain). And winter daytime is on oct 29/30, which is 301 days after January 1st.
  • in US daylight changes on March 12-13, hence day 70 of the year, and winter time on nov 6/7
  • If you're in US locale, could you do the same check with values of 69 and 70 ? @69, you should get 69d and @70, you should get 70d 1h
  • And 307 and 308 ? @307 you should get 307d 1h and @308 you should get 308d

So, my understanding:

  • durationShort.string(from: TimeInterval) computes an endDate starting from Jan 1 of the current year
  • then it evaluates the number of days and hours between startDay (1/1) and this endDate, taking daytime change into account…

However, why make duration start from Jan 1st ?

As documentation is pretty succint:

string(from:) No overview available. 

Framework Foundation

Declaration func string(from dateInterval: DateInterval) -> String?

It could be worth a bug report at least against documentation.

Just FYI, code like this:

let formattedString = durationShort.string(from: TimeInterval(86400 * 213))

is, in general, kinda bogus. Days are not always 86400 seconds long. If you want to calculate a time interval between days, use Calendar.date(byAdding:value:to:wrappingComponents:). For example:

import Foundation

let c = Calendar.current
let now = Date.now
let then = c.date(byAdding: .day, value: 213, to: now)!
print(then.timeIntervalSinceReferenceDate - now.timeIntervalSinceReferenceDate)
// 18399600.0
print(86400.0 * 213.0)
// 18403200.0

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

@eskimo, interesting to know. But the difference is exactly 1 hour (3600). So it is not just a few seconds difference for some days duration. Isn't it due to daylight shift ? It looks strongly correlated to this.

Thank you @Claude31 and @eskimo for your insights. I think that it might be related to daylight shift. I'm still investigating how to handle that. Thank you!

It seems that defining a timezone helps to handle the daylight savning as you can see.

    let formatter = DateComponentsFormatter()
    var calendar = Calendar.current
    calendar.timeZone = TimeZone(identifier: "America/Toronto") ?? .current
    formatter.allowedUnits = [.day, .hour, .minute]
    formatter.unitsStyle = .abbreviated
    formatter.zeroFormattingBehavior = .dropAll
    formatter.maximumUnitCount = 3
    formatter.collapsesLargestUnit = false
    formatter.calendar = calendar
    return formatter
}()

let durationShortAbidjan: DateComponentsFormatter = {
    let formatter = DateComponentsFormatter()
    var calendar = Calendar.current
    calendar.timeZone = TimeZone(identifier: "Africa/Abidjan") ?? .current
    formatter.allowedUnits = [.day, .hour, .minute]
    formatter.unitsStyle = .abbreviated
    formatter.zeroFormattingBehavior = .dropAll
    formatter.maximumUnitCount = 3
    formatter.collapsesLargestUnit = false
    formatter.calendar = calendar
    return formatter
}()

let durationShortTokyo: DateComponentsFormatter = {
    let formatter = DateComponentsFormatter()
    var calendar = Calendar.current
    calendar.timeZone = TimeZone(identifier: "Asia/Tokyo") ?? .current
    formatter.allowedUnits = [.day, .hour, .minute]
    formatter.unitsStyle = .abbreviated
    formatter.zeroFormattingBehavior = .dropAll
    formatter.maximumUnitCount = 3
    formatter.collapsesLargestUnit = false
    formatter.calendar = calendar
    return formatter
}()

let durationToronto = durationShortToronto.string(from: 86400 * 213)  // 213d 1h
let durationAbidjan = durationShortAbidjan.string(from: 86400 * 213)  // 213d
let durationTokyo = durationShortTokyo.string(from: 86400 * 213)      // 213d

It seems that defining a timezone helps to handle the daylight savning as you can see.

No, I think it's wrong. It just confirms that it is a daylight shift issue. It works in Japan and Abidjan because there is no daylight shift in those countries.

There are two ways to interpret 213 days of duration:

  • You start a stopwatch and wait for it to count up to 213 * 24 * 60 * 60 seconds.

  • The time difference between midday today and midday on a day 213 days from now.

The first approach might make sense in some very specific circumstances but the second approach is what most folks think about when you say “213 days from now”.

Your code implements the first approach. If you want the second approach, use my code. Well, actually this code would be better:

let c = Calendar.current
let now = Date.now
var dc = c.dateComponents([.era, .year, .month, .day], from: now)
dc.hour = 12
dc.minute = 0
dc.second = 0
let midday = c.date(from: dc)!
let then = c.date(byAdding: .day, value: 213, to: now)!

When dealing with dates without times, it’s best to work from midday because midnight might not exist [1].

Finally, are you sure you want DateComponentsFormatter and not DateIntervalFormatter?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] See Parsing Dates Without Times for one such pitfall.

DateComponentsFormatter formatted string is not working as expected
 
 
Q