Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
72 changes: 64 additions & 8 deletions Sources/DataTransferObjects/Query/RelativeTimeInterval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions Tests/DataTransferObjectsTests/RelativeDateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")!))
}
}
Loading