Skip to content

Commit 58ef815

Browse files
authored
Add workarounds for Swift’s missing Quarter operations (#65)
1 parent adc8a55 commit 58ef815

4 files changed

Lines changed: 195 additions & 15 deletions

File tree

Package.resolved

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ let package = Package(
1818
],
1919
dependencies: [
2020
// Dependencies declare other packages that this package depends on.
21-
// .package(url: /* package url */, from: "1.0.0"),
22-
.package(url: "https://github.com/TelemetryDeck/SwiftDateOperations.git", from: "2.0.0"),
21+
// .package(name: "SwiftDateOperations", path: "../SwiftDateOperations"), // local development
22+
.package(url: "https://github.com/TelemetryDeck/SwiftDateOperations.git", from: "2.0.1"),
2323
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.8.0"),
2424
],
2525
targets: [

Sources/DataTransferObjects/Query/RelativeTimeInterval.swift

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,21 +66,77 @@ public struct RelativeDate: Codable, Hashable, Equatable, Sendable {
6666
}
6767

6868
public extension Date {
69-
static func from(relativeDate: RelativeDate) -> Date {
70-
var date = Date()
69+
static func from(relativeDate: RelativeDate, originDate: Date? = nil) -> Date {
70+
var date = originDate ?? Date()
7171

7272
let calendarComponent = relativeDate.component.calendarComponent
73-
date = date.calendar.date(byAdding: calendarComponent, value: relativeDate.offset, to: date) ?? date
7473

75-
switch relativeDate.position {
76-
case .beginning:
77-
date = date.beginning(of: calendarComponent) ?? date
78-
case .end:
79-
date = date.end(of: calendarComponent) ?? date
74+
// Swift's Calendar has a known bug where adding/subtracting .quarter doesn't work correctly.
75+
// Work around this by converting quarters to months (1 quarter = 3 months).
76+
if relativeDate.component == .quarter {
77+
date = date.calendar.date(byAdding: .month, value: relativeDate.offset * 3, to: date) ?? date
78+
} else {
79+
date = date.calendar.date(byAdding: calendarComponent, value: relativeDate.offset, to: date) ?? date
80+
}
81+
82+
// Swift's Calendar also has bugs with beginning(of: .quarter) and end(of: .quarter).
83+
// Implement custom quarter boundary logic.
84+
if relativeDate.component == .quarter {
85+
switch relativeDate.position {
86+
case .beginning:
87+
date = date.beginningOfQuarter ?? date
88+
case .end:
89+
date = date.endOfQuarter ?? date
90+
}
91+
} else {
92+
switch relativeDate.position {
93+
case .beginning:
94+
date = date.beginning(of: calendarComponent) ?? date
95+
case .end:
96+
date = date.end(of: calendarComponent) ?? date
97+
}
8098
}
8199

82100
return date
83101
}
102+
103+
/// Returns the first moment of the quarter containing this date.
104+
/// Q1: Jan 1, Q2: Apr 1, Q3: Jul 1, Q4: Oct 1
105+
private var beginningOfQuarter: Date? {
106+
let month = calendar.component(.month, from: self)
107+
let year = calendar.component(.year, from: self)
108+
109+
// Determine the first month of the quarter (1, 4, 7, or 10)
110+
let quarterIndex = (month - 1) / 3 // 0, 1, 2, or 3
111+
let firstMonthOfQuarter = quarterIndex * 3 + 1
112+
113+
var components = DateComponents()
114+
components.year = year
115+
components.month = firstMonthOfQuarter
116+
components.day = 1
117+
components.hour = 0
118+
components.minute = 0
119+
components.second = 0
120+
components.nanosecond = 0
121+
122+
return calendar.date(from: components)
123+
}
124+
125+
/// Returns the last moment of the quarter containing this date.
126+
/// Q1: Mar 31 23:59:59, Q2: Jun 30, Q3: Sep 30, Q4: Dec 31
127+
private var endOfQuarter: Date? {
128+
guard let beginningOfNextQuarter = quarterAfter?.beginningOfQuarter else {
129+
return nil
130+
}
131+
132+
// Subtract 1 second to get the last moment of the current quarter
133+
return calendar.date(byAdding: .second, value: -1, to: beginningOfNextQuarter)
134+
}
135+
136+
/// Returns a date in the next quarter
137+
private var quarterAfter: Date? {
138+
calendar.date(byAdding: .month, value: 3, to: self)
139+
}
84140
}
85141

86142
public extension QueryTimeInterval {

Tests/DataTransferObjectsTests/RelativeDateTests.swift

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,27 @@ final class RelativeDateTests: XCTestCase {
157157
XCTAssertEqual(in30HoursAbsolute, Date.from(relativeDate: in30HoursRelative))
158158
}
159159

160+
func testDateFromRelativeQuarter() throws {
161+
// Jan 12, 2026 is in Q1 2026
162+
// -1 quarter = -3 months → Oct 12, 2025 (Q4 2025)
163+
// Beginning of Q4 2025 = Oct 1, 2025
164+
let beginningOfLastQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -1)
165+
let beginningOfLastQuarterAbsolute = Date(iso8601String: "2025-10-01T00:00:00.000Z")!
166+
167+
XCTAssertEqual(beginningOfLastQuarterAbsolute, Date.from(relativeDate: beginningOfLastQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!))
168+
}
169+
170+
func testDateFromRelativeQuarterOverYear() throws {
171+
// Jan 12, 2026 is in Q1 2026
172+
// -2 quarters = -6 months → Jul 12, 2025 (Q3 2025)
173+
// Beginning of Q3 2025 = Jul 1, 2025
174+
let beginningOfLastQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -2)
175+
let beginningOfLastQuarterAbsolute = Date(iso8601String: "2025-07-01T00:00:00.000Z")!
176+
177+
XCTAssertEqual(beginningOfLastQuarterAbsolute, Date.from(relativeDate: beginningOfLastQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!))
178+
}
179+
180+
160181
func testWeekBeginsOnMonday() throws {
161182
let beginningOfNextWeekRelative = RelativeDate(.beginning, of: .week, adding: 1)
162183
let beginningOfNextWeekAbsolute = Date.from(relativeDate: beginningOfNextWeekRelative)
@@ -167,4 +188,107 @@ final class RelativeDateTests: XCTestCase {
167188

168189
XCTAssertEqual("Monday", weekDay)
169190
}
191+
192+
// MARK: - Comprehensive Quarter Tests
193+
194+
func testQuarterEndCalculation() throws {
195+
// Jan 12, 2026 is in Q1 2026
196+
// End of current quarter (Q1 2026) = Mar 31, 2026 23:59:59
197+
let endOfCurrentQuarterRelative = RelativeDate(.end, of: .quarter, adding: 0)
198+
let endOfCurrentQuarterAbsolute = Date.from(relativeDate: endOfCurrentQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)
199+
200+
// Use UTC calendar for timezone-safe comparison
201+
var calendar = Calendar(identifier: .gregorian)
202+
calendar.timeZone = TimeZone(identifier: "UTC")!
203+
let components = calendar.dateComponents([.year, .month, .day], from: endOfCurrentQuarterAbsolute)
204+
XCTAssertEqual(components.year, 2026)
205+
XCTAssertEqual(components.month, 3)
206+
XCTAssertEqual(components.day, 31)
207+
}
208+
209+
func testQuarterAddingPositiveOffset() throws {
210+
// Jan 12, 2026 is in Q1 2026
211+
// +1 quarter = +3 months → Apr 12, 2026 (Q2 2026)
212+
// Beginning of Q2 2026 = Apr 1, 2026
213+
let beginningOfNextQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: 1)
214+
let beginningOfNextQuarterAbsolute = Date(iso8601String: "2026-04-01T00:00:00.000Z")!
215+
216+
XCTAssertEqual(beginningOfNextQuarterAbsolute, Date.from(relativeDate: beginningOfNextQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!))
217+
}
218+
219+
func testQuarterAddingMultiplePositiveOffsets() throws {
220+
// Jan 12, 2026 is in Q1 2026
221+
// +4 quarters = +12 months → Jan 12, 2027 (Q1 2027)
222+
// Beginning of Q1 2027 = Jan 1, 2027
223+
let beginningOfFourQuartersAheadRelative = RelativeDate(.beginning, of: .quarter, adding: 4)
224+
let beginningOfFourQuartersAheadAbsolute = Date(iso8601String: "2027-01-01T00:00:00.000Z")!
225+
226+
XCTAssertEqual(beginningOfFourQuartersAheadAbsolute, Date.from(relativeDate: beginningOfFourQuartersAheadRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!))
227+
}
228+
229+
func testQuarterCurrentQuarter() throws {
230+
// Jan 12, 2026 is in Q1 2026
231+
// 0 quarters offset = stay in Q1 2026
232+
// Beginning of Q1 2026 = Jan 1, 2026
233+
let beginningOfCurrentQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: 0)
234+
let beginningOfCurrentQuarterAbsolute = Date(iso8601String: "2026-01-01T00:00:00.000Z")!
235+
236+
XCTAssertEqual(beginningOfCurrentQuarterAbsolute, Date.from(relativeDate: beginningOfCurrentQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!))
237+
}
238+
239+
func testQuarterFromMiddleOfQuarter() throws {
240+
// May 15, 2026 is in Q2 2026
241+
// -1 quarter = -3 months → Feb 15, 2026 (Q1 2026)
242+
// Beginning of Q1 2026 = Jan 1, 2026
243+
let beginningOfPreviousQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -1)
244+
let beginningOfPreviousQuarterAbsolute = Date(iso8601String: "2026-01-01T00:00:00.000Z")!
245+
246+
XCTAssertEqual(beginningOfPreviousQuarterAbsolute, Date.from(relativeDate: beginningOfPreviousQuarterRelative, originDate: Date(iso8601String: "2026-05-15T00:00:00.000Z")!))
247+
}
248+
249+
func testQuarterFromEndOfQuarter() throws {
250+
// Mar 31, 2026 is in Q1 2026
251+
// -1 quarter = -3 months → Dec 31, 2025 (Q4 2025)
252+
// Beginning of Q4 2025 = Oct 1, 2025
253+
let beginningOfPreviousQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -1)
254+
let beginningOfPreviousQuarterAbsolute = Date(iso8601String: "2025-10-01T00:00:00.000Z")!
255+
256+
XCTAssertEqual(beginningOfPreviousQuarterAbsolute, Date.from(relativeDate: beginningOfPreviousQuarterRelative, originDate: Date(iso8601String: "2026-03-31T00:00:00.000Z")!))
257+
}
258+
259+
func testQuarterCrossingMultipleYears() throws {
260+
// Jan 12, 2026 is in Q1 2026
261+
// -5 quarters = -15 months → Oct 12, 2024 (Q4 2024)
262+
// Beginning of Q4 2024 = Oct 1, 2024
263+
let beginningOfFiveQuartersBackRelative = RelativeDate(.beginning, of: .quarter, adding: -5)
264+
let beginningOfFiveQuartersBackAbsolute = Date(iso8601String: "2024-10-01T00:00:00.000Z")!
265+
266+
XCTAssertEqual(beginningOfFiveQuartersBackAbsolute, Date.from(relativeDate: beginningOfFiveQuartersBackRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!))
267+
}
268+
269+
func testQuarterEndCrossingYear() throws {
270+
// Jan 12, 2026 is in Q1 2026
271+
// -1 quarter = -3 months → Oct 12, 2025 (Q4 2025)
272+
// End of Q4 2025 = Dec 31, 2025 23:59:59
273+
let endOfPreviousQuarterRelative = RelativeDate(.end, of: .quarter, adding: -1)
274+
let endOfPreviousQuarterAbsolute = Date.from(relativeDate: endOfPreviousQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)
275+
276+
// Use UTC calendar for timezone-safe comparison
277+
var calendar = Calendar(identifier: .gregorian)
278+
calendar.timeZone = TimeZone(identifier: "UTC")!
279+
let components = calendar.dateComponents([.year, .month, .day], from: endOfPreviousQuarterAbsolute)
280+
XCTAssertEqual(components.year, 2025)
281+
XCTAssertEqual(components.month, 12)
282+
XCTAssertEqual(components.day, 31)
283+
}
284+
285+
func testQuarterFromQ3() throws {
286+
// Aug 15, 2025 is in Q3 2025
287+
// -2 quarters = -6 months → Feb 15, 2025 (Q1 2025)
288+
// Beginning of Q1 2025 = Jan 1, 2025
289+
let beginningOfTwoQuartersBackRelative = RelativeDate(.beginning, of: .quarter, adding: -2)
290+
let beginningOfTwoQuartersBackAbsolute = Date(iso8601String: "2025-01-01T00:00:00.000Z")!
291+
292+
XCTAssertEqual(beginningOfTwoQuartersBackAbsolute, Date.from(relativeDate: beginningOfTwoQuartersBackRelative, originDate: Date(iso8601String: "2025-08-15T00:00:00.000Z")!))
293+
}
170294
}

0 commit comments

Comments
 (0)