Schedule Notifications on Day of Month

Today I wanted to implement a notification service in my new App that schedules notifications monthly for any given day.

I thought it would be an easy task because I know there is this iOS API: UNCalendarNotificationTrigger.

With this API, I would schedule a repeat notification matching a given DateComponent like this:

var dateComponents = DateComponents()
dateComponents.day = 2
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)

This all works great until I realize the ending day for each month can be different.

UNCalendarNotificationTrigger will try to get the approximate date using a MatchingPolicy called nextTimePreservingSmallerComponents.

For example, since the last day of February is 28th, if you try to get February 31st, the result of calling nextTriggerDate() of the trigger will return March 1st.

Because I want to notify the user at the end of each month, I have to schedule notifications myself instead of using UNCalendarNotificationTrigger.

Here is what I came up with:

struct AccountNotificationScheduler {
    private let calendar = Calendar.current
    private let notificationHour = 10 // 10:00am
    private let scheduleMonthCount = 12 // Schedule for one year

    private func schedule(for month: Int, dayOfMonth: Int, title: String, body: String, accountUUID: String) {
        var date = Date()
        for index in 0..<month {
            date = nextDate(for: dayOfMonth,
                            startDate: date)
            var components = calendar.dateComponents(in: calendar.timeZone, from: date)

            // There is a bug that quarter must set to nil (default is 0), otherwise the trigger will not fire any event
            // https://stackoverflow.com/questions/41526377/swift-user-notifications-why-is-nexttriggerdate-nil
            components.quarter = nil
            notificationService.notify(matching: components,
                                       title: title,
                                       body: body,
                                       repeats: false,
                                       identifier: "\(index)-"+accountUUID)
        }
    }

    // Next date for dayIndex of a month
    // startDate: the date to begin the search
    private func nextDate(for dayOfMonth: Int,
                          startDate: Date) -> Date {

        var components = DateComponents()
        components.day = dayOfMonth
        components.hour = notificationHour
        components.minute = 0
        components.second = 0
        components.timeZone = calendar.timeZone

        return calendar.nextDate(after: todayNotificationTime, matching: components, matchingPolicy: .previousTimePreservingSmallerComponents)!
    }
}

In the above example, I use a Calendar api nextDate to search for the approximate ending date for each month, then schedule notification individually.

Unlike UNCalendarNotificationTrigger, this API offers a matchingPolicy that I can set to .previousTimePreservingSmallerComponents.

This means it will always look for the nearest possible day while preserving smaller components (hours, minutes, seconds etc.).

Posted 2020-02-14

More writing at jakehao.com