// // SwiftDate // Parse, validate, manipulate, and display dates, time and timezones in Swift // // Created by Daniele Margutti // - Web: https://www.danielemargutti.com // - Twitter: https://twitter.com/danielemargutti // - Mail: hello@danielemargutti.com // // Copyright © 2019 Daniele Margutti. Licensed under MIT License. // import Foundation public extension DateInRegion { // MARK: - Random Date Generator /// Generate a sequence of dates between a range. /// /// - Parameters: /// - count: number of dates to generate. /// - initial: lower date bound. /// - final: upper date bound. /// - region: region of the dates. /// - Returns: array of dates static func randomDates(count: Int, between initial: DateInRegion, and final: DateInRegion, region: Region = SwiftDate.defaultRegion) -> [DateInRegion] { var list: [DateInRegion] = [] for _ in 0.. DateInRegion { let today = DateInRegion(region: region) let earliest = DateInRegion(today.date.addingTimeInterval(TimeInterval(-days * 24 * 60 * 60)), region: region) return DateInRegion.randomDate(between: earliest, and: today) } /// Generate a random date in given region. /// /// - Parameter region: destination region, `nil` to use the default region /// - Returns: random date static func randomDate(region: Region = SwiftDate.defaultRegion) -> DateInRegion { let randomTime = TimeInterval(UInt32.random(in: UInt32.min.. DateInRegion { let interval = final.timeIntervalSince(initial) let randomInterval = TimeInterval(UInt32.random(in: UInt32.min.. DateInRegion? { guard list.count > 0 else { return nil } guard list.count > 1 else { return list.first! } return list.min(by: { return $0 < $1 }) } /// Sort date by oldest, with the oldest date on top. /// /// - Parameter list: list to sort /// - Returns: sorted array static func sortedByOldest(list: [DateInRegion]) -> [DateInRegion] { return list.sorted(by: { $0.date.compare($1.date) == .orderedAscending }) } /// Sort date by newest, with the newest date on top. /// /// - Parameter list: list to sort /// - Returns: sorted array static func sortedByNewest(list: [DateInRegion]) -> [DateInRegion] { return list.sorted(by: { $0.date.compare($1.date) == .orderedDescending }) } /// Return the newest date in given list (timezone is ignored, comparison uses absolute date). /// /// - Parameter list: list of dates /// - Returns: a tuple with the index of the newest date and its instance. static func newestIn(list: [DateInRegion]) -> DateInRegion? { guard list.count > 0 else { return nil } guard list.count > 1 else { return list.first! } return list.max(by: { return $0 < $1 }) } /// Enumerate dates between two intervals by adding specified time components and return an array of dates. /// `startDate` interval will be the first item of the resulting array. /// The last item of the array is evaluated automatically and maybe not equal to `endDate`. /// /// - Parameters: /// - start: starting date /// - endDate: ending date /// - increment: components to add /// - Returns: array of dates static func enumerateDates(from startDate: DateInRegion, to endDate: DateInRegion, increment: DateComponents) -> [DateInRegion] { return DateInRegion.enumerateDates(from: startDate, to: endDate, increment: { _ in return increment }) } /// Enumerate dates between two intervals by adding specified time components defined in a closure and return an array of dates. /// `startDate` interval will be the first item of the resulting array. /// The last item of the array is evaluated automatically and maybe not equal to `endDate`. /// /// - Parameters: /// - start: starting date /// - endDate: ending date /// - increment: increment function. It get the last generated date and require a valida `DateComponents` instance which define the increment /// - Returns: array of dates static func enumerateDates(from startDate: DateInRegion, to endDate: DateInRegion, increment: ((DateInRegion) -> (DateComponents))) -> [DateInRegion] { guard startDate.calendar == endDate.calendar else { debugPrint("Cannot enumerate dates between two different region's calendars. Return empty array.") return [] } var dates: [DateInRegion] = [] var currentDate = startDate while currentDate <= endDate { dates.append(currentDate) currentDate = (currentDate + increment(currentDate)) } return dates } /// Returns a new DateInRegion that is initialized at the start of a specified unit of time. /// /// - Parameter unit: time unit value. /// - Returns: instance at the beginning of the time unit; `self` if fails. func dateAtStartOf(_ unit: Calendar.Component) -> DateInRegion { #if os(Linux) guard let result = (region.calendar as NSCalendar).range(of: unit.nsCalendarUnit, for: date) else { return self } return DateInRegion(result.start, region: region) #else var start: NSDate? var interval: TimeInterval = 0 guard (region.calendar as NSCalendar).range(of: unit.nsCalendarUnit, start: &start, interval: &interval, for: date), let startDate = start else { return self } return DateInRegion(startDate as Date, region: region) #endif } /// Return a new DateInRegion that is initialized at the start of the specified components /// executed in order. /// /// - Parameter units: sequence of transformations as time unit components /// - Returns: new date at the beginning of the passed components, intermediate results if fails. func dateAtStartOf(_ units: [Calendar.Component]) -> DateInRegion { return units.reduce(self) { (currentDate, currentUnit) -> DateInRegion in return currentDate.dateAtStartOf(currentUnit) } } /// Returns a new Moment that is initialized at the end of a specified unit of time. /// /// - parameter unit: time unit value. /// /// - returns: A new Moment instance. func dateAtEndOf(_ unit: Calendar.Component) -> DateInRegion { // RangeOfUnit returns the start of the next unit; we will subtract one thousandth of a second #if os(Linux) guard let result = (region.calendar as NSCalendar).range(of: unit.nsCalendarUnit, for: date) else { return self } let startOfNextUnit = result.start.addingTimeInterval(result.duration) let endOfThisUnit = Date(timeInterval: -0.001, since: startOfNextUnit) return DateInRegion(endOfThisUnit, region: region) #else var start: NSDate? var interval: TimeInterval = 0 guard (self.region.calendar as NSCalendar).range(of: unit.nsCalendarUnit, start: &start, interval: &interval, for: date), let startDate = start else { return self } let startOfNextUnit = startDate.addingTimeInterval(interval) let endOfThisUnit = Date(timeInterval: -0.001, since: startOfNextUnit as Date) return DateInRegion(endOfThisUnit, region: region) #endif } /// Return a new DateInRegion that is initialized at the end of the specified components /// executed in order. /// /// - Parameter units: sequence of transformations as time unit components /// - Returns: new date at the end of the passed components, intermediate results if fails. func dateAtEndOf(_ units: [Calendar.Component]) -> DateInRegion { return units.reduce(self) { (currentDate, currentUnit) -> DateInRegion in return currentDate.dateAtEndOf(currentUnit) } } /// Create a new date by altering specified components of the receiver. /// Note: `calendar` and `timezone` are ignored. /// Note: some components may alter the date cyclically (like setting both `.year` and `.yearForWeekOfYear`) and /// may results in a wrong evaluated date. /// /// - Parameter components: components to alter with their new values. /// - Returns: new altered `DateInRegion` instance func dateBySet(_ components: [Calendar.Component: Int?]) -> DateInRegion? { var dateComponents = DateComponents() dateComponents.year = (components[.year] ?? year) dateComponents.month = (components[.month] ?? month) dateComponents.day = (components[.day] ?? day) dateComponents.hour = (components[.hour] ?? hour) dateComponents.minute = (components[.minute] ?? minute) dateComponents.second = (components[.second] ?? second) dateComponents.nanosecond = (components[.nanosecond] ?? nanosecond) // Some components may interfer with others, so we'll set it them only if explicitly set. if let weekday = components[.weekday] { dateComponents.weekday = weekday } if let weekOfYear = components[.weekOfYear] { dateComponents.weekOfYear = weekOfYear } if let weekdayOrdinal = components[.weekdayOrdinal] { dateComponents.weekdayOrdinal = weekdayOrdinal } if let yearForWeekOfYear = components[.yearForWeekOfYear] { dateComponents.yearForWeekOfYear = yearForWeekOfYear } guard let newDate = calendar.date(from: dateComponents) else { return nil } return DateInRegion(newDate, region: region) } /// Create a new date by altering specified time components. /// /// - Parameters: /// - hour: hour to set (`nil` to leave it unaltered) /// - min: min to set (`nil` to leave it unaltered) /// - secs: sec to set (`nil` to leave it unaltered) /// - ms: milliseconds to set (`nil` to leave it unaltered) /// - options: options for calculation /// - Returns: new altered `DateInRegion` instance func dateBySet(hour: Int?, min: Int?, secs: Int?, ms: Int? = nil, options: TimeCalculationOptions = TimeCalculationOptions()) -> DateInRegion? { guard let date = calendar.date(bySettingHour: (hour ?? self.hour), minute: (min ?? self.minute), second: (secs ?? self.second), of: self.date, matchingPolicy: options.matchingPolicy, repeatedTimePolicy: options.repeatedTimePolicy, direction: options.direction) else { return nil } guard let ms = ms else { return DateInRegion(date, region: region) } var timestamp = date.timeIntervalSince1970.rounded(.down) timestamp += Double(ms) / 1000.0 return DateInRegion(Date(timeIntervalSince1970: timestamp), region: region) } /// Creates a new instance by truncating the components /// /// - Parameter components: components to truncate. /// - Returns: new date with truncated components. func dateTruncated(at components: [Calendar.Component]) -> DateInRegion? { var dateComponents = self.dateComponents for component in components { switch component { case .month: dateComponents.month = 1 case .day: dateComponents.day = 1 case .hour: dateComponents.hour = 0 case .minute: dateComponents.minute = 0 case .second: dateComponents.second = 0 case .nanosecond: dateComponents.nanosecond = 0 default: continue } } guard let newDate = calendar.date(from: dateComponents) else { return nil } return DateInRegion(newDate, region: region) } /// Creates a new instance by truncating the components starting from given components down the granurality. /// /// - Parameter component: The component to be truncated from. /// - Returns: new date with truncated components. func dateTruncated(from component: Calendar.Component) -> DateInRegion? { switch component { case .month: return dateTruncated(at: [.month, .day, .hour, .minute, .second, .nanosecond]) case .day: return dateTruncated(at: [.day, .hour, .minute, .second, .nanosecond]) case .hour: return dateTruncated(at: [.hour, .minute, .second, .nanosecond]) case .minute: return dateTruncated(at: [.minute, .second, .nanosecond]) case .second: return dateTruncated(at: [.second, .nanosecond]) case .nanosecond: return dateTruncated(at: [.nanosecond]) default: return self } } /// Round a given date time to the passed style (off|up|down). /// /// - Parameter style: rounding mode. /// - Returns: rounded date func dateRoundedAt(_ style: RoundDateMode) -> DateInRegion { switch style { case .to5Mins: return dateRoundedAt(.toMins(5)) case .to10Mins: return dateRoundedAt(.toMins(10)) case .to30Mins: return dateRoundedAt(.toMins(30)) case .toCeil5Mins: return dateRoundedAt(.toCeilMins(5)) case .toCeil10Mins: return dateRoundedAt(.toCeilMins(10)) case .toCeil30Mins: return dateRoundedAt(.toCeilMins(30)) case .toFloor5Mins: return dateRoundedAt(.toFloorMins(5)) case .toFloor10Mins: return dateRoundedAt(.toFloorMins(10)) case .toFloor30Mins: return dateRoundedAt(.toFloorMins(30)) case .toMins(let minuteInterval): let onesDigit: Int = (minute % 10) if onesDigit < 5 { return dateRoundedAt(.toFloorMins(minuteInterval)) } else { return dateRoundedAt(.toCeilMins(minuteInterval)) } case .toCeilMins(let minuteInterval): let remain: Int = (minute % minuteInterval) let value = (( Int(1.minutes.timeInterval) * (minuteInterval - remain)) - second) return dateByAdding(value, .second) case .toFloorMins(let minuteInterval): let remain: Int = (minute % minuteInterval) let value = -((Int(1.minutes.timeInterval) * remain) + second) return dateByAdding(value, .second) } } /// Offset a date by n calendar components. /// Note: This operation can be functionally chained. /// /// - Parameters: /// - count: value of the offset (maybe negative). /// - component: component to offset. /// - Returns: new altered date. func dateByAdding(_ count: Int, _ component: Calendar.Component) -> DateInRegion { var newComponent = DateComponents(second: 0) switch component { case .era: newComponent = DateComponents(era: count) case .year: newComponent = DateComponents(year: count) case .month: newComponent = DateComponents(month: count) case .day: newComponent = DateComponents(day: count) case .hour: newComponent = DateComponents(hour: count) case .minute: newComponent = DateComponents(minute: count) case .second: newComponent = DateComponents(second: count) case .weekday: newComponent = DateComponents(weekday: count) case .weekdayOrdinal: newComponent = DateComponents(weekdayOrdinal: count) case .quarter: newComponent = DateComponents(quarter: count) case .weekOfMonth: newComponent = DateComponents(weekOfMonth: count) case .weekOfYear: newComponent = DateComponents(weekOfYear: count) case .yearForWeekOfYear: newComponent = DateComponents(yearForWeekOfYear: count) case .nanosecond: newComponent = DateComponents(nanosecond: count) default: break // .calendar and .timezone does nothing in this context } guard let newDate = region.calendar.date(byAdding: newComponent, to: date) else { return self // failed to add component, return unmodified date } return DateInRegion(newDate, region: region) } /// Return related date starting from the receiver attributes. /// /// - Parameter type: related date to obtain. /// - Returns: instance of the related date; if fails the same unmodified date is returned func dateAt(_ type: DateRelatedType) -> DateInRegion { switch type { case .startOfDay: return calendar.startOfDay(for: date).in(region: region) case .endOfDay: return dateByAdding(1, .day).dateAt(.startOfDay).dateByAdding(-1, .second) case .startOfWeek: let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date) return calendar.date(from: components)!.in(region: region) case .endOfWeek: return dateAt(.startOfWeek).dateByAdding(7, .day).dateByAdding(-1, .second) case .startOfMonth: return dateBySet([.day: 1, .hour: 1, .minute: 1, .second: 1, .nanosecond: 1])! case .endOfMonth: return dateByAdding((monthDays - day), .day).dateAtEndOf(.day) case .tomorrow: return dateByAdding(1, .day) case .tomorrowAtStart: return dateByAdding(1, .day).dateAtStartOf(.day) case .yesterday: return dateByAdding(-1, .day) case .yesterdayAtStart: return dateByAdding(-1, .day).dateAtStartOf(.day) case .nearestMinute(let nearest): let minutes = (minute + nearest / 2) / nearest * nearest return dateBySet([.minute: minutes])! case .nearestHour(let nearest): let hours = (hour + nearest / 2) / nearest * nearest return dateBySet([.hour: hours, .minute: 0])! case .nextWeekday(let weekday): var cal = Calendar(identifier: calendar.identifier) cal.firstWeekday = 2 // Sunday = 1, Saturday = 7 var components = DateComponents() components.weekday = weekday.rawValue guard let next = cal.nextDate(after: date, matching: components, matchingPolicy: .nextTimePreservingSmallerComponents) else { return self } return DateInRegion(next, region: region) case .nextDSTDate: guard let nextDate = region.timeZone.nextDaylightSavingTimeTransition(after: date) else { return self } return DateInRegion(nextDate, region: region) case .prevMonth: return dateByAdding(-1, .month).dateAtStartOf(.month).dateAtStartOf(.day) case .nextMonth: return dateByAdding(1, .month).dateAtStartOf(.month).dateAtStartOf(.day) case .prevWeek: return dateByAdding(-1, .weekOfYear).dateAtStartOf(.weekOfYear).dateAtStartOf(.day) case .nextWeek: return dateByAdding(1, .weekOfYear).dateAtStartOf(.weekOfYear).dateAtStartOf(.day) case .nextYear: return dateByAdding(1, .year).dateAtStartOf(.year) case .prevYear: return dateByAdding(-1, .year).dateAtStartOf(.year) case .nextDSTTransition: guard let transitionDate = region.timeZone.nextDaylightSavingTimeTransition(after: date) else { return self } return DateInRegion(transitionDate, region: region) } } /// Create a new instance of the date in the same region with time shifted by given time interval. /// /// - Parameter interval: time interval to shift; maybe negative. /// - Returns: new instance of the `DateInRegion` func addingTimeInterval(_ interval: TimeInterval) -> DateInRegion { return DateInRegion(date.addingTimeInterval(interval), region: region) } // MARK: - Conversion /// Convert a date to a new calendar/timezone/locale. /// Only non `nil` values are used, other values are inherithed by the receiver's region. /// /// - Parameters: /// - calendar: non `nil` value to change the calendar /// - timezone: non `nil` value to change the timezone /// - locale: non `nil` value to change the locale /// - Returns: converted date func convertTo(calendar: CalendarConvertible? = nil, timezone: ZoneConvertible? = nil, locale: LocaleConvertible? = nil) -> DateInRegion { let newRegion = Region(calendar: (calendar ?? region.calendar), zone: (timezone ?? region.timeZone), locale: (locale ?? region.locale)) return convertTo(region: newRegion) } /// Return the dates for a specific weekday inside given month of specified year. /// Ie. get me all the saturdays of Feb 2018. /// NOTE: Values are returned in order. /// /// - Parameters: /// - weekday: weekday target. /// - month: month target. /// - year: year target. /// - region: region target, omit to use `SwiftDate.defaultRegion` /// - Returns: Ordered list of the dates for given weekday into given month. static func datesForWeekday(_ weekday: WeekDay, inMonth month: Int, ofYear year: Int, region: Region = SwiftDate.defaultRegion) -> [DateInRegion] { let fromDate = DateInRegion(year: year, month: month, day: 1, hour: 0, minute: 0, second: 0, nanosecond: 0, region: region) let toDate = fromDate.dateAt(.endOfMonth) return DateInRegion.datesForWeekday(weekday, from: fromDate, to: toDate, region: region) } /// Return the dates for a specific weekday inside a specified date range. /// NOTE: Values are returned in order. /// /// - Parameters: /// - weekday: weekday target. /// - startDate: from date of the range. /// - endDate: to date of the range. /// - region: region target, omit to use `SwiftDate.defaultRegion` /// - Returns: Ordered list of the dates for given weekday in passed range. static func datesForWeekday(_ weekday: WeekDay, from startDate: DateInRegion, to endDate: DateInRegion, region: Region = SwiftDate.defaultRegion) -> [DateInRegion] { let calendarObj = region.calendar let startDateWeekDay = Int(calendarObj.component(.weekday, from: startDate.date)) let desiredDay = weekday.rawValue let offset = (desiredDay - startDateWeekDay + 7) % 7 let firstOccurrence = calendarObj.startOfDay(for: calendarObj.date(byAdding: DateComponents(day: offset), to: startDate.date)!) guard firstOccurrence.timeIntervalSince1970 < endDate.timeIntervalSince1970 else { return [] } var dateOccurrences = [DateInRegion(firstOccurrence, region: region)] while true { let nextDate = DateInRegion(calendarObj.date(byAdding: DateComponents(day: 7), to: dateOccurrences.last!.date)!, region: region) guard nextDate < endDate else { break } dateOccurrences.append(nextDate) } return dateOccurrences } } public extension DateInRegion { /// Returns the date at the given week number and week day preserving smaller components (hour, minute, seconds) /// /// For example: to get the third friday of next month /// let today = DateInRegion() /// let result = today.dateAt(weekdayOrdinal: 3, weekday: .friday, monthNumber: today.month + 1) /// /// - Parameters: /// - weekdayOrdinal: the week number (by set position in a recurrence rule) /// - weekday: WeekDay /// - monthNumber: a number from 1 to 12 representing the month, optional parameter /// - yearNumber: a number representing the year, optional parameter /// - Returns: new date created with the given parameters func dateAt(weekdayOrdinal: Int, weekday: WeekDay, monthNumber: Int? = nil, yearNumber: Int? = nil) -> DateInRegion { let monthNum = monthNumber ?? month let yearNum = yearNumber ?? year var requiredWeekNum = weekdayOrdinal var result = DateInRegion(year: yearNum, month: monthNum, day: 1, hour: hour, minute: minute, second: second, nanosecond: nanosecond, region: region) if result.weekday == weekday.rawValue { requiredWeekNum -= 1 } while requiredWeekNum > 0 { result = result.nextWeekday(weekday) requiredWeekNum -= 1 } return result } /// Returns the date on the given day of month preserving smaller components func dateAt(dayOfMonth: Int, monthNumber: Int? = nil, yearNumber: Int? = nil) -> DateInRegion { let monthNum = monthNumber ?? month let yearNum = yearNumber ?? year let result = DateInRegion(year: yearNum, month: monthNum, day: dayOfMonth, hour: hour, minute: minute, second: second, nanosecond: nanosecond, region: region) return result } /// Returns the date after given number of weeks on the given day of week func dateAfter(weeks count: Int, on weekday: WeekDay) -> DateInRegion { var result = self.dateByAdding(count, .weekOfMonth) if result.weekday == weekday.rawValue { return result } else if result.weekday > weekday.rawValue { result = result.dateByAdding(-1, .weekOfMonth) } return result.nextWeekday(weekday) } /// Returns the next weekday preserving smaller components /// /// - Parameters: /// - weekday: weekday to get. /// - region: region target, omit to use `SwiftDate.defaultRegion` /// - Returns: `DateInRegion` func nextWeekday(_ weekday: WeekDay) -> DateInRegion { var components = DateComponents() components.weekday = weekday.rawValue components.hour = hour components.second = second components.minute = minute guard let next = region.calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTimePreservingSmallerComponents) else { return self } return DateInRegion(next, region: region) } /// Returns next date with the given weekday and the given week number func next(_ weekday: WeekDay, withWeekOfMonth weekNumber: Int, andMonthNumber monthNumber: Int? = nil) -> DateInRegion { var result = self.dateAt(weekdayOrdinal: weekNumber, weekday: weekday, monthNumber: monthNumber) if result <= self { if let monthNum = monthNumber { result = self.dateAt(weekdayOrdinal: weekNumber, weekday: weekday, monthNumber: monthNum, yearNumber: self.year + 1) } else { result = self.dateAt(weekdayOrdinal: weekNumber, weekday: weekday, monthNumber: self.month + 1) } } return result } /// Returns the next day of month preserving smaller components (hour, minute, seconds) func next(dayOfMonth: Int, monthOfYear: Int? = nil) -> DateInRegion { var components = DateComponents() components.day = dayOfMonth components.month = monthOfYear components.hour = hour components.second = second components.minute = minute guard let next = region.calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTimePreservingSmallerComponents) else { return self } return DateInRegion(next, region: region) } }