Functional binning of values

Today I had to write some code to sort notifications into three bins: Today, Yesterday, and Earlier. I thought I wrote some nice code but in the end got bitten by value semantics. So I started thinking about what a "more functional" solution would look like. I came up with one, but it has the downside of traversing the input array once for each bin, which my original solution didn't have to do, but failed in the face of the value semantics of the bin struct.

I figure this must have a nice, elegant, and purely-functional answer. Seems like a common-enough operation. Here’s the less-efficient way I came up with:

struct
NotificationSection
{
    var         title           :   String
    var         startDate       :   Date
    var         notifications   :   [Notification]
}

struct
Notification
{
    var         message         :   String
    var         createdAt       :   Date
}

//  Assumes inNotifications are sorted reverse-chron…

func
sortIntoSections(notifications inNotifications: [Notification], for inDate: Date = Date())
    -> [NotificationSection]
{
    //  Make a handful of useful sections. “Today”, “Yesterday”, and “Earlier”…
    
    var sections = [NotificationSection]()
    
    let cal = Calendar.current
    let now = inDate
    let startOfToday = cal.startOfDay(for: now)
    var section = NotificationSection(title: "Today",
                                    startDate: startOfToday,
                                    notifications: [Notification]())
    sections.append(section)
    if let startOfYesterday = cal.date(byAdding: .day, value: -1, to: startOfToday)     //  If this fails, we just won’t have a Yesterday section
    {
        section = NotificationSection(title: "Yesterday",
                                        startDate: startOfYesterday,
                                        notifications: [Notification]())
        sections.append(section)
    }
    section = NotificationSection(title: "Earlier",
                                    startDate: Date.distantPast,
                                    notifications: [Notification]())
    sections.append(section)
    
    sections = sections.map
    { inSection in
        var section = inSection
        section.notifications = inNotifications.filter { section.startDate <= $0.createdAt }
        return section
    }
    
    return sections
}

Because the notifications come in in order, it should be possible to stop traversing when the first one is found that does not belong to the current section, and then start traversing again from that point. I tried various ways of composing prefix() and suffix() and dropFirst() but none were particularly nice.

I figured I'm just not very good at thinking functionally, so I'm probably overlooking an elegant solution.

You can use Dictionary.init(grouping:by:) to group a sequence of items into buckets. You pass it your list of notifications, and a closure that converts a notification into one some hashable bucket key. You can then Dictionary.map over that to turn a key-value sequence of bucket keys and notifications into a list of NotificationSections.

1 Like

Not exactly functional, but since the data is sorted, you could also find the pivots and slice them in order:

///  Make a handful of useful sections. “Today”, “Yesterday”, and “Earlier”.
///  Assumes `notifications` are sorted reverse-chronologically.
func sortIntoSections(notifications: [Notification], for now: Date = Date()) -> [NotificationSection] {
    let calendar = Calendar.current, today = calendar.startOfDay(for: now)
    var sections: [NotificationSection] = [], notifications = notifications[...]
    
    /// Call in reverse chronological order
    func slice(date: Date, title: String) -> NotificationSection {
        let endIndex = notifications.firstIndex { date > $0.createdAt } ?? notifications.endIndex
        defer { notifications = notifications[endIndex...] } // Do you need this?
        return NotificationSection(title: title, startDate: date, notifications: .init(notifications[..<endIndex]))
    }
    
    sections.append(slice(date: today, title: "Today"))
    if let yesterday = calendar.date(byAdding: .day, value: -1, to: today) {
        sections.append(slice(date: yesterday, title: "Yesterday"))
    }
    sections.append(slice(date: Date.distantPast, title: "Earlier")) // Could use custom item here
    
    return sections
}

Your code would put Today items in the Yesterday section, so maybe you don't need the defer block.

Suppose we had a function that splits a Collection into two SubSequences at the first Element satisfying a predicate. Then we could split the array of Notifications at the first element older than today, giving us today's notes and not-today's notes. Then we could split not-today's notes at the first element older then yesterday, giving us yesterday's notes and not-yesterday's notes.

Here's the splitter:

extension Collection {
    func firstSplit(
        where predicate: (Element) throws -> Bool
    ) rethrows -> (
        front: SubSequence, // none of these satisfy `predicate`
        back: SubSequence // the first of these (if it exists) satisfies `predicate`
    ) {
        let index = try firstIndex(where: predicate) ?? endIndex
        return (front: self[..<index], back: self[index...])
    }
}

We can use it like this:

extension Collection where Element == Notification {
    func sectioned(asOf now: Date) -> [NotificationSection] {
        let cal = Calendar.current
        let startOfToday = cal.startOfDay(for: now)
        let startOfYesterday = cal.date(byAdding: .day, value: -1, to: startOfToday)

        let (front: todayNotes, back: notTodayNotes) = self.firstSplit { $0.createdAt < startOfToday }

        let yesterdayNotes: SubSequence, earlierNotes: SubSequence
        if let startOfYesterday = startOfYesterday {
            (front: yesterdayNotes, back: earlierNotes) = notTodayNotes.firstSplit { $0.createdAt < startOfYesterday }
        } else {
            yesterdayNotes = notTodayNotes[..<notTodayNotes.startIndex]
            earlierNotes = notTodayNotes
        }

        func make(_ title: String, _ start: Date?, _ notes: SubSequence) -> NotificationSection? {
            guard let start = start else { return nil }
            return .init(title: title, startDate: start, notifications: .init(notes))
        }

        return [
            make("Today", startOfToday, todayNotes),
            make("Yesterday", startOfYesterday, yesterdayNotes),
            make("Earlier", .distantPast, earlierNotes)
        ].compactMap { $0 }
    }
}
3 Likes
Terms of Service

Privacy Policy

Cookie Policy