From d34952641a9423a0971b8e63e8dd8ce870f46dde Mon Sep 17 00:00:00 2001 From: Daniel Jilg Date: Tue, 6 Jan 2026 16:10:17 +0100 Subject: [PATCH] =?UTF-8?q?Add=20workarounds=20for=20Swift=E2=80=99s=20mis?= =?UTF-8?q?sing=20Quarter=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.resolved | 10 +- Package.swift | 4 +- .../Query/RelativeTimeInterval.swift | 72 ++++++++-- .../RelativeDateTests.swift | 124 ++++++++++++++++++ 4 files changed, 195 insertions(+), 15 deletions(-) diff --git a/Package.resolved b/Package.resolved index 8619006..7763f79 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "d17c94abeac2fe916c4c167caafdaeb10540ebc564c324d666dfe0b7f75071eb", + "originHash" : "d4b2d263e046baf7bb3ab0b81f6eedd85d4d027cf47962c255a7fff09651b51a", "pins" : [ { "identity" : "swift-asn1", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", - "version" : "1.5.0" + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TelemetryDeck/SwiftDateOperations.git", "state" : { - "revision" : "2d9dea9b05d9d8eb2e1c2ce9fed1d4a274ffe900", - "version" : "2.0.0" + "revision" : "ae414202ac985cf9bb78244ba365e82d1d9e7a4c", + "version" : "2.0.1" } } ], diff --git a/Package.swift b/Package.swift index 63a9550..d2fc3cd 100644 --- a/Package.swift +++ b/Package.swift @@ -18,8 +18,8 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(url: "https://github.com/TelemetryDeck/SwiftDateOperations.git", from: "2.0.0"), + // .package(name: "SwiftDateOperations", path: "../SwiftDateOperations"), // local development + .package(url: "https://github.com/TelemetryDeck/SwiftDateOperations.git", from: "2.0.1"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.8.0"), ], targets: [ diff --git a/Sources/DataTransferObjects/Query/RelativeTimeInterval.swift b/Sources/DataTransferObjects/Query/RelativeTimeInterval.swift index c3fd83c..e1d3a3e 100644 --- a/Sources/DataTransferObjects/Query/RelativeTimeInterval.swift +++ b/Sources/DataTransferObjects/Query/RelativeTimeInterval.swift @@ -66,21 +66,77 @@ public struct RelativeDate: Codable, Hashable, Equatable, Sendable { } public extension Date { - static func from(relativeDate: RelativeDate) -> Date { - var date = Date() + static func from(relativeDate: RelativeDate, originDate: Date? = nil) -> Date { + var date = originDate ?? Date() let calendarComponent = relativeDate.component.calendarComponent - date = date.calendar.date(byAdding: calendarComponent, value: relativeDate.offset, to: date) ?? date - switch relativeDate.position { - case .beginning: - date = date.beginning(of: calendarComponent) ?? date - case .end: - date = date.end(of: calendarComponent) ?? date + // Swift's Calendar has a known bug where adding/subtracting .quarter doesn't work correctly. + // Work around this by converting quarters to months (1 quarter = 3 months). + if relativeDate.component == .quarter { + date = date.calendar.date(byAdding: .month, value: relativeDate.offset * 3, to: date) ?? date + } else { + date = date.calendar.date(byAdding: calendarComponent, value: relativeDate.offset, to: date) ?? date + } + + // Swift's Calendar also has bugs with beginning(of: .quarter) and end(of: .quarter). + // Implement custom quarter boundary logic. + if relativeDate.component == .quarter { + switch relativeDate.position { + case .beginning: + date = date.beginningOfQuarter ?? date + case .end: + date = date.endOfQuarter ?? date + } + } else { + switch relativeDate.position { + case .beginning: + date = date.beginning(of: calendarComponent) ?? date + case .end: + date = date.end(of: calendarComponent) ?? date + } } return date } + + /// Returns the first moment of the quarter containing this date. + /// Q1: Jan 1, Q2: Apr 1, Q3: Jul 1, Q4: Oct 1 + private var beginningOfQuarter: Date? { + let month = calendar.component(.month, from: self) + let year = calendar.component(.year, from: self) + + // Determine the first month of the quarter (1, 4, 7, or 10) + let quarterIndex = (month - 1) / 3 // 0, 1, 2, or 3 + let firstMonthOfQuarter = quarterIndex * 3 + 1 + + var components = DateComponents() + components.year = year + components.month = firstMonthOfQuarter + components.day = 1 + components.hour = 0 + components.minute = 0 + components.second = 0 + components.nanosecond = 0 + + return calendar.date(from: components) + } + + /// Returns the last moment of the quarter containing this date. + /// Q1: Mar 31 23:59:59, Q2: Jun 30, Q3: Sep 30, Q4: Dec 31 + private var endOfQuarter: Date? { + guard let beginningOfNextQuarter = quarterAfter?.beginningOfQuarter else { + return nil + } + + // Subtract 1 second to get the last moment of the current quarter + return calendar.date(byAdding: .second, value: -1, to: beginningOfNextQuarter) + } + + /// Returns a date in the next quarter + private var quarterAfter: Date? { + calendar.date(byAdding: .month, value: 3, to: self) + } } public extension QueryTimeInterval { diff --git a/Tests/DataTransferObjectsTests/RelativeDateTests.swift b/Tests/DataTransferObjectsTests/RelativeDateTests.swift index 26c7480..af4aa87 100644 --- a/Tests/DataTransferObjectsTests/RelativeDateTests.swift +++ b/Tests/DataTransferObjectsTests/RelativeDateTests.swift @@ -157,6 +157,27 @@ final class RelativeDateTests: XCTestCase { XCTAssertEqual(in30HoursAbsolute, Date.from(relativeDate: in30HoursRelative)) } + func testDateFromRelativeQuarter() throws { + // Jan 12, 2026 is in Q1 2026 + // -1 quarter = -3 months → Oct 12, 2025 (Q4 2025) + // Beginning of Q4 2025 = Oct 1, 2025 + let beginningOfLastQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -1) + let beginningOfLastQuarterAbsolute = Date(iso8601String: "2025-10-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfLastQuarterAbsolute, Date.from(relativeDate: beginningOfLastQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)) + } + + func testDateFromRelativeQuarterOverYear() throws { + // Jan 12, 2026 is in Q1 2026 + // -2 quarters = -6 months → Jul 12, 2025 (Q3 2025) + // Beginning of Q3 2025 = Jul 1, 2025 + let beginningOfLastQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -2) + let beginningOfLastQuarterAbsolute = Date(iso8601String: "2025-07-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfLastQuarterAbsolute, Date.from(relativeDate: beginningOfLastQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)) + } + + func testWeekBeginsOnMonday() throws { let beginningOfNextWeekRelative = RelativeDate(.beginning, of: .week, adding: 1) let beginningOfNextWeekAbsolute = Date.from(relativeDate: beginningOfNextWeekRelative) @@ -167,4 +188,107 @@ final class RelativeDateTests: XCTestCase { XCTAssertEqual("Monday", weekDay) } + + // MARK: - Comprehensive Quarter Tests + + func testQuarterEndCalculation() throws { + // Jan 12, 2026 is in Q1 2026 + // End of current quarter (Q1 2026) = Mar 31, 2026 23:59:59 + let endOfCurrentQuarterRelative = RelativeDate(.end, of: .quarter, adding: 0) + let endOfCurrentQuarterAbsolute = Date.from(relativeDate: endOfCurrentQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!) + + // Use UTC calendar for timezone-safe comparison + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let components = calendar.dateComponents([.year, .month, .day], from: endOfCurrentQuarterAbsolute) + XCTAssertEqual(components.year, 2026) + XCTAssertEqual(components.month, 3) + XCTAssertEqual(components.day, 31) + } + + func testQuarterAddingPositiveOffset() throws { + // Jan 12, 2026 is in Q1 2026 + // +1 quarter = +3 months → Apr 12, 2026 (Q2 2026) + // Beginning of Q2 2026 = Apr 1, 2026 + let beginningOfNextQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: 1) + let beginningOfNextQuarterAbsolute = Date(iso8601String: "2026-04-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfNextQuarterAbsolute, Date.from(relativeDate: beginningOfNextQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)) + } + + func testQuarterAddingMultiplePositiveOffsets() throws { + // Jan 12, 2026 is in Q1 2026 + // +4 quarters = +12 months → Jan 12, 2027 (Q1 2027) + // Beginning of Q1 2027 = Jan 1, 2027 + let beginningOfFourQuartersAheadRelative = RelativeDate(.beginning, of: .quarter, adding: 4) + let beginningOfFourQuartersAheadAbsolute = Date(iso8601String: "2027-01-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfFourQuartersAheadAbsolute, Date.from(relativeDate: beginningOfFourQuartersAheadRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)) + } + + func testQuarterCurrentQuarter() throws { + // Jan 12, 2026 is in Q1 2026 + // 0 quarters offset = stay in Q1 2026 + // Beginning of Q1 2026 = Jan 1, 2026 + let beginningOfCurrentQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: 0) + let beginningOfCurrentQuarterAbsolute = Date(iso8601String: "2026-01-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfCurrentQuarterAbsolute, Date.from(relativeDate: beginningOfCurrentQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)) + } + + func testQuarterFromMiddleOfQuarter() throws { + // May 15, 2026 is in Q2 2026 + // -1 quarter = -3 months → Feb 15, 2026 (Q1 2026) + // Beginning of Q1 2026 = Jan 1, 2026 + let beginningOfPreviousQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -1) + let beginningOfPreviousQuarterAbsolute = Date(iso8601String: "2026-01-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfPreviousQuarterAbsolute, Date.from(relativeDate: beginningOfPreviousQuarterRelative, originDate: Date(iso8601String: "2026-05-15T00:00:00.000Z")!)) + } + + func testQuarterFromEndOfQuarter() throws { + // Mar 31, 2026 is in Q1 2026 + // -1 quarter = -3 months → Dec 31, 2025 (Q4 2025) + // Beginning of Q4 2025 = Oct 1, 2025 + let beginningOfPreviousQuarterRelative = RelativeDate(.beginning, of: .quarter, adding: -1) + let beginningOfPreviousQuarterAbsolute = Date(iso8601String: "2025-10-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfPreviousQuarterAbsolute, Date.from(relativeDate: beginningOfPreviousQuarterRelative, originDate: Date(iso8601String: "2026-03-31T00:00:00.000Z")!)) + } + + func testQuarterCrossingMultipleYears() throws { + // Jan 12, 2026 is in Q1 2026 + // -5 quarters = -15 months → Oct 12, 2024 (Q4 2024) + // Beginning of Q4 2024 = Oct 1, 2024 + let beginningOfFiveQuartersBackRelative = RelativeDate(.beginning, of: .quarter, adding: -5) + let beginningOfFiveQuartersBackAbsolute = Date(iso8601String: "2024-10-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfFiveQuartersBackAbsolute, Date.from(relativeDate: beginningOfFiveQuartersBackRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!)) + } + + func testQuarterEndCrossingYear() throws { + // Jan 12, 2026 is in Q1 2026 + // -1 quarter = -3 months → Oct 12, 2025 (Q4 2025) + // End of Q4 2025 = Dec 31, 2025 23:59:59 + let endOfPreviousQuarterRelative = RelativeDate(.end, of: .quarter, adding: -1) + let endOfPreviousQuarterAbsolute = Date.from(relativeDate: endOfPreviousQuarterRelative, originDate: Date(iso8601String: "2026-01-12T00:00:00.000Z")!) + + // Use UTC calendar for timezone-safe comparison + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let components = calendar.dateComponents([.year, .month, .day], from: endOfPreviousQuarterAbsolute) + XCTAssertEqual(components.year, 2025) + XCTAssertEqual(components.month, 12) + XCTAssertEqual(components.day, 31) + } + + func testQuarterFromQ3() throws { + // Aug 15, 2025 is in Q3 2025 + // -2 quarters = -6 months → Feb 15, 2025 (Q1 2025) + // Beginning of Q1 2025 = Jan 1, 2025 + let beginningOfTwoQuartersBackRelative = RelativeDate(.beginning, of: .quarter, adding: -2) + let beginningOfTwoQuartersBackAbsolute = Date(iso8601String: "2025-01-01T00:00:00.000Z")! + + XCTAssertEqual(beginningOfTwoQuartersBackAbsolute, Date.from(relativeDate: beginningOfTwoQuartersBackRelative, originDate: Date(iso8601String: "2025-08-15T00:00:00.000Z")!)) + } }