-
Notifications
You must be signed in to change notification settings - Fork 31
Description
Short explanation
FHIRModels defines an extension on TimeZone
, fhirDescription
, which is used by DateTime.description
and Instant.description
.
TimeZone.fhirDescription
returns a String that is either Z
(for GMT), or a formatted hour:minutes offset.
The issue at hand is that this currently is implemented incorrectly, in that the actual offset of a time zone is not static but rather changes throughout the year and should always be computed relative to some specific date.
For example, TimeZone(identifier: "America/Los_Angeles")
has a different GMT offset in january (no DST) as compared to may (DST).
However, TimeZone.fhirDescription
doesn't take this into account and instead always computes the offset for to the current date, when instead it should be computing the offset for the date for which the time zone description is being computed.
TimeZone.fhirDescription
is used by DateTime.description
and Instant.description
, both of which as a result are also wrong for some dates.
Suggested Fix:
Since the repo doesn't allow PRs, i'll instead describe the fix here:
- turn
TimeZone.fhirDescription
into a function that takes aDate
(the date for which the offset should be computed) - adjust
DateTime.description
andInstant.description
to call this function, and pass in their respectiveasNSDate()
representations (with a fallback toDate()
if theNSDate
representation is nil
extension TimeZone {
func fhirDescription(for date: Date) -> String {
let gmtOffset = secondsFromGMT(for: date)
if gmtOffset == 0 {
return "Z"
}
let ahead = (gmtOffset > 0)
let seconds = abs(gmtOffset)
let hours = seconds / 3600
let minutes = (seconds - (3600 * hours)) / 60
let prefix = ahead ? "+" : "-"
return String(format: "\(prefix)%02d:%02d", hours, minutes)
}
}
extension DateTime: CustomStringConvertible {
public var description: String {
if let time = time, let timeZone = timeZone {
if _timeZoneIsUnaltered, let originalTimeZoneString = originalTimeZoneString {
return "\(date.description)T\(time.description)\(originalTimeZoneString)"
}
return "\(date.description)T\(time.description)\(timeZone.fhirDescription(for: (try? asNSDate()) ?? Date()))"
}
return date.description
}
}
// analogously for Instant
Reproduction
Here's a little XCTest test case demonstrating this issue:
let cal = Calendar.current
let timeZone = TimeZone(identifier: "America/Los_Angeles")!
let DF = DateFormatter()
DF.timeZone = timeZone
DF.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
let date1 = cal.date(from: .init(timeZone: timeZone, year: 2025, month: 1, day: 10, hour: 12))!
let date2 = cal.date(from: .init(timeZone: timeZone, year: 2025, month: 5, day: 10, hour: 12))!
let fhirDate1 = try DateTime(date: date1, timeZone: timeZone)
let fhirDate2 = try DateTime(date: date2, timeZone: timeZone)
XCTAssertEqual(try fhirDate1.asNSDate(), date1)
XCTAssertEqual(try fhirDate2.asNSDate(), date2)
XCTAssertEqual(fhirDate1.description, DF.string(from: date1))
// ^ will fail: XCTAssertEqual failed: ("2025-01-10T12:00:00-07:00") is not equal to ("2025-01-10T12:00:00-08:00")
XCTAssertEqual(fhirDate2.description, DF.string(from: date2))
// ^ will succeed