Skip to content

Commit a7020a3

Browse files
authored
[GregorianCalendar] Implement TimeZone support for date(from components: DateComponents) (swiftlang#311)
* [GregorianCalendar] Implement TimeZone support for `date(from components: DateComponents)` To support DST-observing time zone, add a helper function for TimeZone to return the raw offset and DST offset individually so we can fine tune the behavior for the time during the skipped time frame and the repeated time frame. * remove an accidental import * Add non-compatibility tests * Review feedback: Remove mention of "GMT" in the date argument * Review feedback: Change the returning type of DST offset from Int to TimeInterval to be consistent with the existing dstOffset API * Implement the required function for _TimeZoneBridged * Fix a missing import
1 parent 1ea90b7 commit a7020a3

File tree

12 files changed

+267
-19
lines changed

12 files changed

+267
-19
lines changed

Sources/FoundationEssentials/Calendar/Calendar_Gregorian.swift

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ enum ResolvedDateComponents {
9090

9191
init(dateComponents components: DateComponents) {
9292
var (year, month) = Self.yearMonth(forDateComponent: components)
93-
let minMonth = 1
9493
let minWeekdayOrdinal = 1
9594
if let d = components.day {
9695
if components.yearForWeekOfYear != nil, let weekOfYear = components.weekOfYear {
@@ -125,7 +124,6 @@ enum ResolvedDateComponents {
125124
}
126125

127126
init(preferComponent c: Calendar.Component, dateComponents components: DateComponents) {
128-
let minMonth = 1
129127
let minWeekdayOrdinal = 1
130128
switch c {
131129
case .day:
@@ -464,7 +462,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
464462
}
465463

466464

467-
func date(from components: DateComponents, inTimeZone timeZone: TimeZone?, resolvedComponents: ResolvedDateComponents? = nil) -> Date? {
465+
func date(from components: DateComponents, inTimeZone timeZone: TimeZone, resolvedComponents: ResolvedDateComponents? = nil) -> Date? {
468466

469467
let resolvedComponents = resolvedComponents ?? ResolvedDateComponents(dateComponents: components)
470468

@@ -498,20 +496,14 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
498496
secondsInDay += Double(nanosecond) / Double(nano_coef)
499497
}
500498

501-
let timeZoneOffset: Int
502-
if let timeZone = timeZone {
503-
// TODO: Implement `TimeZone.secondsFromGMT(for date: Date)` to support DST
504-
timeZoneOffset = timeZone.secondsFromGMT()
505-
} else if let timeZone = components.timeZone {
506-
timeZoneOffset = timeZone.secondsFromGMT()
507-
} else {
508-
timeZoneOffset = 0 // Assume GMT
509-
}
499+
// Rewind from Julian day, which starts at noon, back to midnight
500+
var tmpDate = Date(julianDay: julianDay) - 43200 + secondsInDay
510501

511-
let timeInThisDay = secondsInDay - Double(timeZoneOffset)
502+
// tmpDate now is in GMT. Adjust it back into local time zone
503+
let (timeZoneOffset, dstOffset) = timeZone.rawAndDaylightSavingTimeOffset(for: tmpDate)
504+
tmpDate = tmpDate - Double(timeZoneOffset) - dstOffset
512505

513-
// rewind from Julian day, which starts at noon, back to midnight
514-
return Date(julianDay: julianDay) - 43200 + timeInThisDay
506+
return tmpDate
515507
}
516508

517509

Sources/FoundationEssentials/TimeZone/TimeZone.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ public struct TimeZone : Hashable, Equatable, Sendable {
152152
_tz.secondsFromGMT(for: date)
153153
}
154154

155+
internal func rawAndDaylightSavingTimeOffset(for date: Date) -> (rawOffset: Int, daylightSavingOffset: TimeInterval) {
156+
_tz.rawAndDaylightSavingTimeOffset(for: date)
157+
}
158+
155159
/// Returns the abbreviation for the time zone at a given date.
156160
///
157161
/// Note that the abbreviation may be different at different dates. For example, during daylight saving time the US/Eastern time zone has an abbreviation of "EDT." At other times, its abbreviation is "EST."

Sources/FoundationEssentials/TimeZone/TimeZone_Autoupdating.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ internal final class _TimeZoneAutoupdating : _TimeZoneProtocol, Sendable {
5151
TimeZoneCache.cache.current.localizedName(for: style, locale: locale)
5252
}
5353

54+
func rawAndDaylightSavingTimeOffset(for date: Date) -> (rawOffset: Int, daylightSavingOffset: TimeInterval) {
55+
TimeZoneCache.cache.current.rawAndDaylightSavingTimeOffset(for: date)
56+
}
57+
5458
var isAutoupdating: Bool {
5559
true
5660
}

Sources/FoundationEssentials/TimeZone/TimeZone_GMT.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ package final class _TimeZoneGMT : _TimeZoneProtocol, @unchecked Sendable {
4747
0.0
4848
}
4949

50+
package func rawAndDaylightSavingTimeOffset(for date: Date) -> (rawOffset: Int, daylightSavingOffset: TimeInterval) {
51+
(offset, 0)
52+
}
53+
5054
package func nextDaylightSavingTimeTransition(after date: Date) -> Date? {
5155
nil
5256
}

Sources/FoundationEssentials/TimeZone/TimeZone_Protocol.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ package protocol _TimeZoneProtocol : AnyObject, Sendable, CustomDebugStringConve
1717

1818
var identifier: String { get }
1919
func secondsFromGMT(for date: Date) -> Int
20+
21+
/// Essentially this is equivalent to adjusting `date` to this time zone using `rawOffset`, then passing the adjusted date to `daylightSavingTimeOffset(for: <adjusted date>)`.
22+
/// This also handles the skipped time frame on DST start day differently from `daylightSavingTimeOffset(:)`, where dates in the skipped time frame are considered *not* in DST here, hence the DST offset would be 0.
23+
func rawAndDaylightSavingTimeOffset(for date: Date) -> (rawOffset: Int, daylightSavingOffset: TimeInterval)
24+
2025
func abbreviation(for date: Date) -> String?
2126
func isDaylightSavingTime(for date: Date) -> Bool
2227
func daylightSavingTimeOffset(for date: Date) -> TimeInterval

Sources/FoundationInternationalization/Calendar/Calendar_ICU.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1729,7 +1729,7 @@ internal final class _CalendarICU: _CalendarProtocol, @unchecked Sendable {
17291729
let start = date - 48.0 * 60.0 * 60.0
17301730

17311731

1732-
guard let nextDSTTransition = _locked_nextDaylightSavingTimeTransition(startingAt: start, limit: start + 4 * 8600 * 1000.0) else {
1732+
guard let nextDSTTransition = _locked_nextDaylightSavingTimeTransition(startingAt: start, limit: start + 4 * 86400 * 1000.0) else {
17331733
return nil
17341734
}
17351735

Sources/FoundationInternationalization/TimeZone/TimeZone_Bridge.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ internal final class _TimeZoneBridged: _TimeZoneProtocol, @unchecked Sendable {
8282
func localizedName(for style: TimeZone.NameStyle, locale: Locale?) -> String? {
8383
_timeZone.localizedName(style, locale: locale)
8484
}
85-
85+
86+
func rawAndDaylightSavingTimeOffset(for date: Date) -> (rawOffset: Int, daylightSavingOffset: TimeInterval) {
87+
(_timeZone.secondsFromGMT(for: date), _timeZone.daylightSavingTimeOffset(for: date))
88+
}
89+
8690
func bridgeToNSTimeZone() -> NSTimeZone {
8791
_timeZone.copy() as! NSTimeZone
8892
}

Sources/FoundationInternationalization/TimeZone/TimeZone_GMTICU.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ internal final class _TimeZoneGMTICU : _TimeZoneProtocol, @unchecked Sendable {
6161
nil
6262
}
6363

64+
func rawAndDaylightSavingTimeOffset(for date: Date) -> (rawOffset: Int, daylightSavingOffset: TimeInterval) {
65+
(offset, 0)
66+
}
67+
6468
var debugDescription: String {
6569
"gmt icu offset \(offset)"
6670
}

Sources/FoundationInternationalization/TimeZone/TimeZone_ICU.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,26 @@ internal final class _TimeZoneICU: _TimeZoneProtocol, Sendable {
162162
}
163163
}
164164

165+
func rawAndDaylightSavingTimeOffset(for date: Date) -> (rawOffset: Int, daylightSavingOffset: TimeInterval) {
166+
return lock.withLock {
167+
guard let calendar = $0.calendar(identifier) else { return (0, 0) }
168+
var rawOffset: Int32 = 0
169+
var dstOffset: Int32 = 0
170+
var status = U_ZERO_ERROR
171+
let origMillis = ucal_getMillis(calendar, &status)
172+
defer {
173+
ucal_setMillis(calendar, origMillis, &status)
174+
}
175+
ucal_setMillis(calendar, date.udate, &status)
176+
177+
// If the date falls into the skipped time frame when transitioning into DST (e.g. 1:00 - 3:00 AM for PDT), we want to treat it as if DST hasn't happened yet. So, use UCAL_TZ_LOCAL_FORMER for nonExistingTimeOpt.
178+
// If the date falls into the repeated time frame when DST ends (e.g. 1:00 - 2:00 AM for PDT), we want the first instance, i.e. the instance before turning back the clock. So, use UCAL_TZ_LOCAL_FORMER for duplicatedTimeOpt.
179+
ucal_getTimeZoneOffsetFromLocal(calendar, UCAL_TZ_LOCAL_FORMER, UCAL_TZ_LOCAL_FORMER, &rawOffset, &dstOffset, &status)
180+
181+
return (Int(rawOffset / 1000), TimeInterval(dstOffset / 1000))
182+
}
183+
}
184+
165185
func localizedName(for style: TimeZone.NameStyle, locale: Locale?) -> String? {
166186
let locID = locale?.identifier ?? ""
167187
return lock.withLock {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
#if canImport(TestSupport)
14+
import TestSupport
15+
#endif
16+
17+
#if canImport(FoundationEssentials)
18+
@testable import FoundationEssentials
19+
#endif
20+
21+
// Tests for _GregorianCalendar
22+
final class GregorianCalendarTests : XCTestCase {
23+
24+
func testDateFromComponents_DST() {
25+
// The expected dates were generated using ICU Calendar
26+
27+
let tz = TimeZone(identifier: "America/Los_Angeles")!
28+
let gregorianCalendar = _CalendarGregorian(identifier: .gregorian, timeZone: tz, locale: nil, firstWeekday: nil, minimumDaysInFirstWeek: nil, gregorianStartDate: nil)
29+
func test(_ dateComponents: DateComponents, expected: Date, file: StaticString = #file, line: UInt = #line) {
30+
let date = gregorianCalendar.date(from: dateComponents)!
31+
XCTAssertEqual(date, expected, "DateComponents: \(dateComponents)", file: file, line: line)
32+
}
33+
34+
test(.init(year: 2023, month: 10, day: 16), expected: Date(timeIntervalSince1970: 1697439600.0))
35+
test(.init(year: 2023, month: 10, day: 16, hour: 1, minute: 34, second: 52), expected: Date(timeIntervalSince1970: 1697445292.0))
36+
test(.init(year: 2023, month: 11, day: 6), expected: Date(timeIntervalSince1970: 1699257600.0))
37+
test(.init(year: 2023, month: 3, day: 12), expected: Date(timeIntervalSince1970: 1678608000.0))
38+
test(.init(year: 2023, month: 3, day: 12, hour: 1, minute: 34, second: 52), expected: Date(timeIntervalSince1970: 1678613692.0))
39+
test(.init(year: 2023, month: 3, day: 12, hour: 2, minute: 34, second: 52), expected: Date(timeIntervalSince1970: 1678617292.0))
40+
test(.init(year: 2023, month: 3, day: 12, hour: 3, minute: 34, second: 52), expected: Date(timeIntervalSince1970: 1678617292.0))
41+
test(.init(year: 2023, month: 3, day: 13, hour: 0, minute: 0, second: 0), expected: Date(timeIntervalSince1970: 1678690800.0))
42+
test(.init(year: 2023, month: 11, day: 5), expected: Date(timeIntervalSince1970: 1699167600.0))
43+
test(.init(year: 2023, month: 11, day: 5, hour: 1, minute: 34, second: 52), expected: Date(timeIntervalSince1970: 1699173292.0))
44+
test(.init(year: 2023, month: 11, day: 5, hour: 2, minute: 34, second: 52), expected: Date(timeIntervalSince1970: 1699180492.0))
45+
test(.init(year: 2023, month: 11, day: 5, hour: 3, minute: 34, second: 52), expected: Date(timeIntervalSince1970: 1699184092.0))
46+
}
47+
48+
func testDateFromComponents() {
49+
// The expected dates were generated using ICU Calendar
50+
let tz = TimeZone.gmt
51+
let cal = _CalendarGregorian(identifier: .gregorian, timeZone: tz, locale: nil, firstWeekday: 1, minimumDaysInFirstWeek: 4, gregorianStartDate: nil)
52+
func test(_ dateComponents: DateComponents, expected: Date, file: StaticString = #file, line: UInt = #line) {
53+
let date = cal.date(from: dateComponents)
54+
XCTAssertEqual(date, expected, "date components: \(dateComponents)", file: file, line: line)
55+
}
56+
57+
test(.init(year: 1582, month: -7, weekday: 5, weekdayOrdinal: 0), expected: Date(timeIntervalSince1970: -12264739200.0))
58+
test(.init(year: 1582, month: -3, weekday: 0, weekdayOrdinal: -5), expected: Date(timeIntervalSince1970: -12253680000.0))
59+
test(.init(year: 1582, month: 5, weekday: -2, weekdayOrdinal: 3), expected: Date(timeIntervalSince1970: -12231475200.0))
60+
test(.init(year: 1582, month: 5, weekday: 4, weekdayOrdinal: 6), expected: Date(timeIntervalSince1970: -12229747200.0))
61+
test(.init(year: 2014, month: -4, weekday: -1, weekdayOrdinal: 4), expected: Date(timeIntervalSince1970: 1377216000.0))
62+
test(.init(year: 2446, month: -1, weekday: -1, weekdayOrdinal: -1), expected: Date(timeIntervalSince1970: 15017875200.0))
63+
test(.init(year: 2878, month: -9, weekday: -9, weekdayOrdinal: 1), expected: Date(timeIntervalSince1970: 28627603200.0))
64+
test(.init(year: 2878, month: -5, weekday: 1, weekdayOrdinal: -6), expected: Date(timeIntervalSince1970: 28636934400.0))
65+
test(.init(year: 2878, month: 7, weekday: -7, weekdayOrdinal: 8), expected: Date(timeIntervalSince1970: 28673740800.0))
66+
test(.init(year: 2878, month: 11, weekday: -1, weekdayOrdinal: 4), expected: Date(timeIntervalSince1970: 28682121600.0))
67+
68+
test(.init(year: 1582, month: -7, day: 2), expected: Date(timeIntervalSince1970: -12264307200.0))
69+
test(.init(year: 1582, month: 1, day: -1), expected: Date(timeIntervalSince1970: -12243398400.0))
70+
test(.init(year: 1705, month: 6, day: -6), expected: Date(timeIntervalSince1970: -8350128000.0))
71+
test(.init(year: 1705, month: 6, day: 3), expected: Date(timeIntervalSince1970: -8349350400.0))
72+
test(.init(year: 1828, month: -9, day: -3), expected: Date(timeIntervalSince1970: -4507920000.0))
73+
test(.init(year: 1828, month: 3, day: 0), expected: Date(timeIntervalSince1970: -4476038400.0))
74+
test(.init(year: 1828, month: 7, day: 5), expected: Date(timeIntervalSince1970: -4465065600.0))
75+
test(.init(year: 2074, month: -4, day: 2), expected: Date(timeIntervalSince1970: 3268857600.0))
76+
test(.init(year: 2197, month: 5, day: -2), expected: Date(timeIntervalSince1970: 7173619200.0))
77+
test(.init(year: 2197, month: 5, day: 1), expected: Date(timeIntervalSince1970: 7173878400.0))
78+
test(.init(year: 2320, month: -2, day: -2), expected: Date(timeIntervalSince1970: 11036649600.0))
79+
test(.init(year: 2320, month: 6, day: -3), expected: Date(timeIntervalSince1970: 11057644800.0))
80+
test(.init(year: 2443, month: 7, day: 5), expected: Date(timeIntervalSince1970: 14942448000.0))
81+
test(.init(year: 2812, month: 5, day: 4), expected: Date(timeIntervalSince1970: 26581651200.0))
82+
test(.init(year: 2935, month: 6, day: -3), expected: Date(timeIntervalSince1970: 30465158400.0))
83+
test(.init(year: 2935, month: 6, day: 3), expected: Date(timeIntervalSince1970: 30465676800.0))
84+
85+
test(.init(year: 1582, month: 5, weekOfMonth: -2), expected: Date(timeIntervalSince1970: -12232857600.0))
86+
test(.init(year: 1582, month: 5, weekOfMonth: 4), expected: Date(timeIntervalSince1970: -12232857600.0))
87+
test(.init(year: 1705, month: 2, weekOfMonth: 1), expected: Date(timeIntervalSince1970: -8359891200.0))
88+
test(.init(year: 1705, month: 6, weekOfMonth: -3), expected: Date(timeIntervalSince1970: -8349523200.0))
89+
test(.init(year: 1828, month: 7, weekOfMonth: 2), expected: Date(timeIntervalSince1970: -4465411200.0))
90+
test(.init(year: 1828, month: 7, weekOfMonth: 5), expected: Date(timeIntervalSince1970: -4465411200.0))
91+
test(.init(year: 1828, month: 11, weekOfMonth: 0), expected: Date(timeIntervalSince1970: -4454784000.0))
92+
test(.init(year: 2197, month: 5, weekOfMonth: -2), expected: Date(timeIntervalSince1970: 7173878400.0))
93+
test(.init(year: 2197, month: 5, weekOfMonth: 1), expected: Date(timeIntervalSince1970: 7173878400.0))
94+
test(.init(year: 2320, month: 2, weekOfMonth: 1), expected: Date(timeIntervalSince1970: 11047536000.0))
95+
test(.init(year: 2320, month: 6, weekOfMonth: -3), expected: Date(timeIntervalSince1970: 11057990400.0))
96+
test(.init(year: 2443, month: -5, weekOfMonth: 4), expected: Date(timeIntervalSince1970: 14910566400.0))
97+
test(.init(year: 2443, month: -1, weekOfMonth: -1), expected: Date(timeIntervalSince1970: 14921193600.0))
98+
test(.init(year: 2443, month: 7, weekOfMonth: -1), expected: Date(timeIntervalSince1970: 14942102400.0))
99+
test(.init(year: 2443, month: 7, weekOfMonth: 2), expected: Date(timeIntervalSince1970: 14942102400.0))
100+
test(.init(year: 2812, month: -3, weekOfMonth: -3), expected: Date(timeIntervalSince1970: 26560396800.0))
101+
test(.init(year: 2812, month: 5, weekOfMonth: 1), expected: Date(timeIntervalSince1970: 26581392000.0))
102+
test(.init(year: 2812, month: 5, weekOfMonth: 4), expected: Date(timeIntervalSince1970: 26581392000.0))
103+
test(.init(year: 2935, month: 6, weekOfMonth: 0), expected: Date(timeIntervalSince1970: 30465504000.0))
104+
105+
test(.init(weekOfYear: 20, yearForWeekOfYear: 1582), expected: Date(timeIntervalSince1970: -12231820800.0))
106+
test(.init(weekOfYear: -25, yearForWeekOfYear: 1705), expected: Date(timeIntervalSince1970: -8378035200.0))
107+
test(.init(weekOfYear: -4, yearForWeekOfYear: 1705), expected: Date(timeIntervalSince1970: -8365334400.0))
108+
test(.init(weekOfYear: 3, yearForWeekOfYear: 1705), expected: Date(timeIntervalSince1970: -8361100800.0))
109+
test(.init(weekOfYear: 0, yearForWeekOfYear: 1828), expected: Date(timeIntervalSince1970: -4481913600.0))
110+
test(.init(weekOfYear: 25, yearForWeekOfYear: 1951), expected: Date(timeIntervalSince1970: -585187200.0))
111+
test(.init(weekOfYear: -34, yearForWeekOfYear: 2074), expected: Date(timeIntervalSince1970: 3260736000.0))
112+
test(.init(weekOfYear: 1, yearForWeekOfYear: 2074), expected: Date(timeIntervalSince1970: 3281904000.0))
113+
test(.init(weekOfYear: 8, yearForWeekOfYear: 2074), expected: Date(timeIntervalSince1970: 3286137600.0))
114+
test(.init(weekOfYear: -1, yearForWeekOfYear: 2443), expected: Date(timeIntervalSince1970: 14925513600.0))
115+
test(.init(weekOfYear: 3, yearForWeekOfYear: 2566), expected: Date(timeIntervalSince1970: 18808934400.0))
116+
test(.init(weekOfYear: 0, yearForWeekOfYear: 2689), expected: Date(timeIntervalSince1970: 22688726400.0))
117+
test(.init(weekOfYear: -52, yearForWeekOfYear: 2812), expected: Date(timeIntervalSince1970: 26538883200.0))
118+
test(.init(weekOfYear: 1, yearForWeekOfYear: 2935), expected: Date(timeIntervalSince1970: 30452544000.0))
119+
test(.init(weekOfYear: 43, yearForWeekOfYear: 2935), expected: Date(timeIntervalSince1970: 30477945600.0))
120+
}
121+
}

0 commit comments

Comments
 (0)