Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Timezone Support for ScheduleBuilder #138

Closed
wants to merge 4 commits into from
Closed
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
80 changes: 80 additions & 0 deletions Sources/Queues/ScheduleBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,35 @@ public final class ScheduleBuilder: @unchecked Sendable {
let builder: ScheduleBuilder

public func `in`(_ month: Month) -> Monthly { self.builder.month = month; return self.builder.monthly() }

public func `in`(_ month: Month, timezone: TimeZone) -> Monthly {
self.builder.timezone = timezone
return self.in(month)
}
}

/// An object to build a `Monthly` scheduled job
public struct Monthly {
let builder: ScheduleBuilder

public func on(_ day: Day) -> Daily { self.builder.day = day; return self.builder.daily() }

public func on(_ day: Day, timezone: TimeZone) -> Daily {
self.builder.timezone = timezone
return self.on(day)
}
}

/// An object to build a `Weekly` scheduled job
public struct Weekly {
let builder: ScheduleBuilder

public func on(_ weekday: Weekday) -> Daily { self.builder.weekday = weekday; return self.builder.daily() }

public func on(_ weekday: Weekday, timezone: TimeZone) -> Daily {
self.builder.timezone = timezone
return self.on(weekday)
}
}

/// An object to build a `Daily` scheduled job
Expand All @@ -164,20 +179,63 @@ public final class ScheduleBuilder: @unchecked Sendable {
public func at(_ hour: Hour24, _ minute: Minute) { self.at(.init(hour, minute)) }

public func at(_ hour: Hour12, _ minute: Minute, _ period: HourPeriod) { self.at(.init(hour, minute, period)) }

public func at(_ time: Time, timezone identifier: String) {
if let tz = TimeZone(identifier: identifier) {
self.builder.timezone = tz
} else if let tz = TimeZone(abbreviation: identifier) {
self.builder.timezone = tz
} else {
self.builder.timezone = TimeZone(identifier: "UTC")!
}
self.at(time)
}

public func at(_ hour: Hour24, _ minute: Minute, timezone identifier: String) {
if let tz = TimeZone(identifier: identifier) {
self.builder.timezone = tz
} else if let tz = TimeZone(abbreviation: identifier) {
self.builder.timezone = tz
} else {
self.builder.timezone = TimeZone(identifier: "UTC")!
}
self.at(hour, minute)
}

public func at(_ hour: Hour12, _ minute: Minute, _ period: HourPeriod, timezone identifier: String) {
if let tz = TimeZone(identifier: identifier) {
self.builder.timezone = tz
} else if let tz = TimeZone(abbreviation: identifier) {
self.builder.timezone = tz
} else {
self.builder.timezone = TimeZone(identifier: "UTC")!
}
self.at(hour, minute, period)
}
}

/// An object to build a `Hourly` scheduled job
public struct Hourly {
let builder: ScheduleBuilder

public func at(_ minute: Minute) { self.builder.minute = minute }

public func at(_ minute: Minute, timezone: TimeZone) {
self.builder.timezone = timezone
self.at(minute)
}
}

/// An object to build a `EveryMinute` scheduled job
public struct Minutely {
let builder: ScheduleBuilder

public func at(_ second: Second) { self.builder.second = second }

public func at(_ second: Second, timezone: TimeZone) {
self.builder.timezone = timezone
self.at(second)
}
}

/// Retrieves the next date after the one given.
Expand All @@ -197,12 +255,18 @@ public final class ScheduleBuilder: @unchecked Sendable {
default: break
}
components.month = self.month?.rawValue
if let timezone = self.timezone {
self.calendar.timeZone = timezone
}
return calendar.nextDate(after: current, matching: components, matchingPolicy: .strict)
}

/// The calendar used to compute the next date
var calendar: Calendar

/// The timezone for the schedule
var timezone: TimeZone?

/// Date to perform task (one-off job)
var date: Date?
var month: Month?, day: Day?, weekday: Weekday?
Expand All @@ -212,6 +276,9 @@ public final class ScheduleBuilder: @unchecked Sendable {

/// Schedules a job using a specific `Calendar`
public func using(_ calendar: Calendar) -> ScheduleBuilder { self.calendar = calendar; return self }

/// Schedules a job using a specific timezone
public func `in`(timezone: TimeZone) -> ScheduleBuilder { self.timezone = timezone; return self }

/// Schedules a job at a specific date
public func at(_ date: Date) { self.date = date }
Expand All @@ -236,4 +303,17 @@ public final class ScheduleBuilder: @unchecked Sendable {

/// Runs a job every second
public func everySecond() { self.millisecond = 0 }

/// Schedules a job using a specific timezone identifier
@discardableResult
public func `in`(timezone identifier: String) -> ScheduleBuilder {
if let tz = TimeZone(identifier: identifier) {
self.timezone = tz
} else if let tz = TimeZone(abbreviation: identifier) {
self.timezone = tz
} else {
self.timezone = TimeZone(identifier: "UTC")!
}
return self
}
}
134 changes: 134 additions & 0 deletions Tests/QueuesTests/ScheduleBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,140 @@ final class ScheduleBuilderTests: XCTestCase {
)
}

func testTimezoneConfiguration() throws {
// Test timezone initialization
let nyBuilder = ScheduleBuilder()
.in(timezone: TimeZone(identifier: "America/New_York")!)

// Schedule job for 9am New York time
nyBuilder.daily().at("9:00am")

// Reference dates
let nyDate = Date(
calendar: .calendar(timezone: "America/New_York"),
hour: 9,
minute: 0
)

let utcDate = Date(
calendar: .calendar(timezone: "UTC"),
hour: 14, // 9am NY = 2pm UTC
minute: 0
)

// Test that next run time is correct regardless of input timezone
XCTAssertEqual(
nyBuilder.nextDate(current: nyDate.addingTimeInterval(-3600)),
nyDate
)
XCTAssertEqual(
nyBuilder.nextDate(current: utcDate.addingTimeInterval(-3600)),
utcDate
)
}

func testTimezoneAcrossDateBoundary() throws {
let tokyoBuilder = ScheduleBuilder()
.in(timezone: TimeZone(identifier: "Asia/Tokyo")!)

// Schedule for midnight Tokyo time
tokyoBuilder.daily().at(.midnight)

// Create a reference date: January 1, 2020 11:00 PM Los Angeles time
// At this time, it's already January 2, 2020 4:00 PM in Tokyo
let laDate = Date(
calendar: .calendar(timezone: "America/Los_Angeles"),
year: 2020,
month: 1,
day: 1,
hour: 23, // 11 PM LA time
minute: 0
)

// The next midnight in Tokyo (January 3, 2020 00:00 Tokyo time)
// will be January 2, 2020 7:00 AM LA time
let expectedDate = Date(
calendar: .calendar(timezone: "America/Los_Angeles"),
year: 2020,
month: 1,
day: 2,
hour: 7, // 7 AM LA = midnight Tokyo (next day)
minute: 0
)

XCTAssertEqual(
tokyoBuilder.nextDate(current: laDate),
expectedDate,
"When it's 11 PM on Jan 1 in LA, the next midnight in Tokyo should be 7 AM on Jan 2 LA time"
)
}

func testTimezoneWithYearlySchedule() throws {
let dubaiBuilder = ScheduleBuilder()
.in(timezone: TimeZone(identifier: "Asia/Dubai")!)

// Schedule for New Year's Day at noon Dubai time
dubaiBuilder.yearly()
.in(.january)
.on(.first)
.at(.noon)

// Test from December 31st London time
let londonDate = Date(
calendar: .calendar(timezone: "Europe/London"),
year: 2020, // Add explicit year
month: 12,
day: 31,
hour: 8, // 8am London = noon Dubai
minute: 0
)

let expectedDate = Date(
calendar: .calendar(timezone: "Europe/London"),
year: 2021, // Next year
month: 1,
day: 1,
hour: 8, // 8am London = noon Dubai
minute: 0
)

XCTAssertEqual(
dubaiBuilder.nextDate(current: londonDate),
expectedDate
)
}

func testTimezoneConsistency() throws {
let sydneyBuilder = ScheduleBuilder()
.in(timezone: TimeZone(identifier: "Australia/Sydney")!)

// Schedule for 3pm Sydney time
sydneyBuilder.daily().at("3:00pm")

// Test across multiple days to ensure DST handling
let startDate = Date(
calendar: .calendar(timezone: "Australia/Sydney"),
month: 4, // April (DST transition month in Australia)
day: 1,
hour: 15,
minute: 0
)

var currentDate = startDate
for _ in 1...5 {
let nextDate = sydneyBuilder.nextDate(current: currentDate)
XCTAssertNotNil(nextDate)

// Verify time remains at 3pm Sydney time
let components = Calendar.calendar(timezone: "Australia/Sydney")
.dateComponents([.hour, .minute], from: nextDate!)

XCTAssertEqual(components.hour, 15) // 3pm = 15:00
XCTAssertEqual(components.minute, 0)

currentDate = nextDate!
}
}
}

final class Cleanup: ScheduledJob {
Expand Down