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