Large switch statement bucketing system

Posted on

Problem

I’ve finally got a solution for a “weekly streak” bucketing system I’ve been working on but unfortunately it’s resulted in a very large switch statement.

I’m running through a handful of dates and determining by a range of seconds (the length of a week) which bucket that date belongs to.

It’s obvious to me that this could be refactored in a programmatic way because of the amount of repetition but I’m not sure where to start. Dictionary? Some kind of sorting algorithm? Recursion?

open func weeklyStreakCount(weeklyGoal target: Int) -> Int {
    let endDate = Date()
    let startDate = endDate.startOfWeek!
    let startDateInterval = Double(startDate.timeIntervalSinceNow)

    var workoutsPerWeek = [Int: Int]()
    let userWorkouts: [UserWorkoutEntity] = self.userWorkouts(completed: true)

    var numberOfGoodBuckets = 0

    for i in 0...100 {
        workoutsPerWeek.updateValue(0, forKey: i)
    }

    // calculate the time from now to seconds in a week and round to the nearest hundreths to create a bucket for that week
    for userWorkout in userWorkouts {
        let workoutTimeInterval = Double((userWorkout.completionDate?.timeIntervalSinceNow)!)
        let rawBucket = (startDateInterval - workoutTimeInterval) / numberOfSecondsInAWeek

        let bucket = Int(rawBucket * 1000)
        let abbrNumberOfSecondsInAWeek = 604

        switch bucket {
        case 0 ... abbrNumberOfSecondsInAWeek:
            workoutsPerWeek[0]! += 1
        case abbrNumberOfSecondsInAWeek ... (abbrNumberOfSecondsInAWeek * 2):
            workoutsPerWeek[1]! += 1
        case abbrNumberOfSecondsInAWeek * 2 ... (abbrNumberOfSecondsInAWeek * 3):
            workoutsPerWeek[2]! += 1
        case abbrNumberOfSecondsInAWeek * 3 ... (abbrNumberOfSecondsInAWeek * 4):
            workoutsPerWeek[3]! += 1
        case abbrNumberOfSecondsInAWeek * 4 ... (abbrNumberOfSecondsInAWeek * 5):
            workoutsPerWeek[4]! += 1
        case abbrNumberOfSecondsInAWeek * 5 ... (abbrNumberOfSecondsInAWeek * 6):
            workoutsPerWeek[5]! += 1
        case abbrNumberOfSecondsInAWeek * 6 ... (abbrNumberOfSecondsInAWeek * 7):
            workoutsPerWeek[6]! += 1
        case abbrNumberOfSecondsInAWeek * 7 ... (abbrNumberOfSecondsInAWeek * 8):
            workoutsPerWeek[7]! += 1
        case abbrNumberOfSecondsInAWeek * 8 ... (abbrNumberOfSecondsInAWeek * 9):
            workoutsPerWeek[8]! += 1
        case abbrNumberOfSecondsInAWeek * 9 ... (abbrNumberOfSecondsInAWeek * 10):
            workoutsPerWeek[9]! += 1
        default:
            break
        }
    }

    // Run through each bucket and see how many times the user hit their goal
    for i in 0...10 {
        if(workoutsPerWeek[i]! > target) {
            numberOfGoodBuckets += 1
        } else {
            break
        }
    }
    return numberOfGoodBuckets
}

Solution

First, I would replace the dictionary

var workoutsPerWeek = [Int: Int]()

by an array. A dictionary could be memory-saving if you have “few” keys in a “large range”,
e.g. 1, 20, 300, 4000, as a “sparse-array” emulation. But that is not the case here:
The bucket numbers range from 0 to 9, so that an array is more appropriate,
easier to initialize, and easier to access (no forced unwraps needed).

It is unclear in your code how many entries are needed: You
initialize entries for 0...100, update entries for 0...9, and finally
evaluate entries 0...10. If you want to collect the data for the last 10 weeks then it would be

var workoutsPerWeek = Array(repeating: 0, count: 10)

Now you can use the computed bucket number as an index into the array and
replace the switch statement by

let rawBucket = Int((startDateInterval - workoutTimeInterval) / numberOfSecondsInAWeek)
if rawBucket >= 0 && rawBucket < workoutsPerWeek.count {
    workoutsPerWeek[rawBucket] += 1
}

The range check for the bucket number can also be done as

if workoutsPerWeek.indices.contains(rawBucket) { ... }

There are more things which can be improved: In

let workoutTimeInterval = Double((userWorkout.completionDate?.timeIntervalSinceNow)!)

the conversion to Double is not needed. And what if completionDate is nil?
Your code would crash in that case.

But my main point of criticism is how the bucket is computed.
A day does not necessarily have 24 hours. In regions with daylight saving time,
a day can have 23 or 25 hours if the clocks are adjusted one hour back or forward.
Applied to your code: A week does not necessarily have 604,800 seconds.

The proper way to compute calendar differences is to use the Calendar methods
and DateComponents:

for userWorkout in userWorkouts {
    // Ensure that completionDate is set, otherwise ignore this entry
    guard let completionDate = userWorkout.completionDate else { continue }

    // Compute #of weeks between completionDate and startDate
    let weeksAgo = Calendar.current.dateComponents([.weekOfMonth], from: completionDate, to: startDate).weekOfMonth!

    // Update corresponding bucket
    if workoutsPerWeek.indices.contains(weeksAgo) {
        workoutsPerWeek[weeksAgo] += 1
    }
}

Finally, the calculation of the number of consecutive weeks where the goal has been reached can be simplified to

let numberOfGoodBuckets = workoutsPerWeek.prefix(while: { $0 >= target }).count

Leave a Reply

Your email address will not be published. Required fields are marked *