Skip to content

Bug report: TimeZone.fhirDescription doesn't account for DST, leading to incorrect values returned from DateTime.description and Instant.description, #35

@lukaskollmer

Description

@lukaskollmer

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 a Date (the date for which the offset should be computed)
  • adjust DateTime.description and Instant.description to call this function, and pass in their respective asNSDate() representations (with a fallback to Date() if the NSDate 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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions