Repository: go-co-op/gocron
Pull request #79 (2020-11-19 00:20:37)

refact: scheduleNextRun now uses duration to schedule next run
### What does this do?
refactors scheduleNextRun to use duration to schedule next run, basically laying the ground to use the time package for scheduling jobs

I was starting to refactor our scheduler to use the time package to schedule next runs instead of iterating over our slice and checking if each job should run and realized that we would need to have the duration between our last run and our next expected run.

The old scheduleNextRun was sort of doing this but assigning it directly to the job instead of returning the duration, so I decided to first refactor this part and make the change scope a little more focused instead of bringing all the refactor at once.

The code and the tests related with it ended up becoming simpler, so that was good! Tell me what you guys think. 

Now, we calculate the duration like this:

Job every 3 seconds last run at 01:00:00. Next run should be lastRun + 3 seconds == 01:00:03 (let's call this value Y). So next run will receive the value of Y.Sub(lastRun), which returns 3 seconds. This may seem redundant work but is needed for other usecases that are non-duration type by nature such as days weeks and months.


Patch:
diff --git a/go.mod b/go.mod
index 5baf982..2fb923f 100644
--- a/go.mod
+++ b/go.mod
@@ -2,11 +2,4 @@ module github.com/go-co-op/gocron
 
 go 1.13
 
-require (
-	github.com/go-redis/redis v6.15.5+incompatible
-	github.com/onsi/ginkgo v1.10.1 // indirect
-	github.com/onsi/gomega v1.7.0 // indirect
-	github.com/stretchr/testify v1.4.0
-	golang.org/x/net v0.0.0-20200226121028-0de0cce0169b // indirect
-	golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e // indirect
-)
+require github.com/stretchr/testify v1.4.0
diff --git a/go.sum b/go.sum
index 72fa774..8fdee58 100644
--- a/go.sum
+++ b/go.sum
@@ -1,44 +1,11 @@
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/go-redis/redis v6.15.5+incompatible h1:pLky8I0rgiblWfa8C1EV7fPEUv0aH6vKRaYHc/YRHVk=
-github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
-github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
-github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
-github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
-github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
-gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/job.go b/job.go
index e49a0f8..2d0b52d 100644
--- a/job.go
+++ b/job.go
@@ -6,10 +6,12 @@ import (
 	"time"
 )
 
+type jobInterval uint64
+
 // Job struct stores the information necessary to run a Job
 type Job struct {
 	sync.RWMutex
-	interval          uint64                   // pause interval * unit between runs
+	interval          jobInterval              // pause interval * unit between runs
 	unit              timeUnit                 // time units, ,e.g. 'minutes', 'hours'...
 	startsImmediately bool                     // if the Job should run upon scheduler start
 	jobFunc           string                   // the Job jobFunc to run, func[jobFunc]
@@ -34,7 +36,7 @@ type runConfig struct {
 // NewJob creates a new Job with the provided interval
 func NewJob(interval uint64) *Job {
 	return &Job{
-		interval: interval,
+		interval: jobInterval(interval),
 		lastRun:  time.Time{},
 		nextRun:  time.Time{},
 		funcs:    make(map[string]interface{}),
diff --git a/scheduler.go b/scheduler.go
index 64e1816..1702e27 100644
--- a/scheduler.go
+++ b/scheduler.go
@@ -14,7 +14,7 @@ type Scheduler struct {
 	jobs []*Job
 	loc  *time.Location
 
-	running  bool
+	running  bool          // represents if the scheduler is running at the moment or not
 	stopChan chan struct{} // signal to stop scheduling
 
 	time timeWrapper // wrapper around time.Time
@@ -86,124 +86,146 @@ func (s *Scheduler) ChangeLocation(newLocation *time.Location) {
 }
 
 // scheduleNextRun Compute the instant when this Job should run next
-func (s *Scheduler) scheduleNextRun(j *Job) {
-	j.Lock()
-	defer j.Unlock()
+func (s *Scheduler) scheduleNextRun(job *Job) {
+	job.Lock()
+	defer job.Unlock()
 	now := s.time.Now(s.loc)
 
-	if j.startsImmediately {
-		j.nextRun = now
-		j.startsImmediately = false
+	if job.startsImmediately {
+		job.nextRun = now
+		job.startsImmediately = false
 		return
 	}
 
-	// delta represent the time slice used to calculate the next run
-	// it can be the last time ran by a job, or time.Now() if the job never ran
-	var delta time.Time
-	if j.neverRan() {
-		if !j.nextRun.IsZero() { // scheduled for future run, wait to run at least once
-			return
-		}
-		delta = now
-	} else {
-		delta = j.lastRun
+	if job.neverRan() && !job.nextRun.IsZero() {
+		return // scheduled for future run and should skip scheduling
 	}
+	job.lastRun = now
+
+	durationToNextRun := s.durationToNextRun(job)
+	job.nextRun = job.lastRun.Add(durationToNextRun)
+}
 
-	switch j.unit {
+func (s *Scheduler) durationToNextRun(job *Job) time.Duration {
+	lastRun := job.lastRun
+	var duration time.Duration
+	switch job.unit {
 	case seconds, minutes, hours:
-		j.nextRun = s.rescheduleDuration(j, delta)
+		duration = s.calculateDuration(job)
 	case days:
-		j.nextRun = s.rescheduleDay(j, delta)
+		duration = s.calculateDays(job, lastRun)
 	case weeks:
-		j.nextRun = s.rescheduleWeek(j, delta)
+		if job.scheduledWeekday != nil { // weekday selected, Every().Monday(), for example
+			duration = s.calculateWeekday(job, lastRun)
+		} else {
+			duration = s.calculateWeeks(job, lastRun)
+		}
 	case months:
-		j.nextRun = s.rescheduleMonth(j, delta)
+		duration = s.calculateMonths(job, lastRun)
+	}
+	return duration
+}
+
+func (s Scheduler) getJobLastRun(job *Job) time.Time {
+	if job.neverRan() {
+		return s.time.Now(s.loc)
 	}
+	return job.lastRun
 }
 
-func (s *Scheduler) rescheduleMonth(j *Job, delta time.Time) time.Time {
-	if j.neverRan() { // calculate days to j.dayOfTheMonth
-		jobDay := time.Date(delta.Year(), delta.Month(), j.dayOfTheMonth, 0, 0, 0, 0, s.loc).Add(j.atTime)
-		daysDifference := int(math.Abs(delta.Sub(jobDay).Hours()) / 24)
-		nextRun := s.roundToMidnight(delta)
-		if jobDay.Before(delta) { // shouldn't run this month; schedule for next interval minus day difference
+func (s *Scheduler) calculateMonths(job *Job, lastRun time.Time) time.Duration {
+	lastRunRoundedMidnight := s.roundToMidnight(lastRun)
 
-			nextRun = nextRun.AddDate(0, int(j.interval), -daysDifference)
+	if job.dayOfTheMonth > 0 { // calculate days to j.dayOfTheMonth
+		jobDay := time.Date(lastRun.Year(), lastRun.Month(), job.dayOfTheMonth, 0, 0, 0, 0, s.loc).Add(job.atTime)
+		daysDifference := int(math.Abs(lastRun.Sub(jobDay).Hours()) / 24)
+		nextRun := s.roundToMidnight(lastRun).Add(job.atTime)
+		if jobDay.Before(lastRun) { // shouldn't run this month; schedule for next interval minus day difference
+			nextRun = nextRun.AddDate(0, int(job.interval), -daysDifference)
 		} else {
-			if j.interval == 1 { // every month counts current month
-				nextRun = nextRun.AddDate(0, int(j.interval)-1, daysDifference)
+			if job.interval == 1 { // every month counts current month
+				nextRun = nextRun.AddDate(0, int(job.interval)-1, daysDifference)
 			} else { // should run next month interval
-				nextRun = nextRun.AddDate(0, int(j.interval), daysDifference)
+				nextRun = nextRun.AddDate(0, int(job.interval), daysDifference)
 			}
 		}
-		return nextRun.Add(j.atTime)
+		return s.until(lastRunRoundedMidnight, nextRun)
 	}
-	return s.roundToMidnight(delta).AddDate(0, int(j.interval), 0).Add(j.atTime)
+	nextRun := lastRunRoundedMidnight.Add(job.atTime).AddDate(0, int(job.interval), 0)
+	return s.until(lastRunRoundedMidnight, nextRun)
 }
 
-func (s *Scheduler) rescheduleWeek(j *Job, delta time.Time) time.Time {
-	var days int
-	if j.scheduledWeekday != nil { // weekday selected, Every().Monday(), for example
-		days = s.calculateWeekdayDifference(delta, j)
-	} else {
-		days = int(j.interval) * 7
-	}
-	delta = s.roundToMidnight(delta)
-	return delta.AddDate(0, 0, days).Add(j.atTime)
+func (s *Scheduler) calculateWeekday(job *Job, lastRun time.Time) time.Duration {
+	daysToWeekday := remainingDaysToWeekday(lastRun.Weekday(), *job.scheduledWeekday)
+	totalDaysDifference := s.calculateTotalDaysDifference(lastRun, daysToWeekday, job)
+	nextRun := s.roundToMidnight(lastRun).Add(job.atTime).AddDate(0, 0, totalDaysDifference)
+	return s.until(lastRun, nextRun)
+}
+
+func (s *Scheduler) calculateWeeks(job *Job, lastRun time.Time) time.Duration {
+	totalDaysDifference := int(job.interval) * 7
+	nextRun := s.roundToMidnight(lastRun).Add(job.atTime).AddDate(0, 0, totalDaysDifference)
+	return s.until(lastRun, nextRun)
 }
 
-func (s *Scheduler) rescheduleDay(j *Job, delta time.Time) time.Time {
-	if j.interval == 1 {
-		atTime := time.Date(delta.Year(), delta.Month(), delta.Day(), 0, 0, 0, 0, s.loc).Add(j.atTime)
-		if delta.Before(atTime) { // should run today
-			return s.roundToMidnight(delta).Add(j.atTime)
+func (s *Scheduler) calculateTotalDaysDifference(lastRun time.Time, daysToWeekday int, job *Job) int {
+	if job.interval > 1 { // every N weeks counts rest of this week and full N-1 weeks
+		return daysToWeekday + int(job.interval-1)*7
+	}
+
+	if daysToWeekday == 0 { // today, at future time or already passed
+		lastRunAtTime := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day(), 0, 0, 0, 0, s.loc).Add(job.atTime)
+		if lastRun.Before(lastRunAtTime) || lastRun.Equal(lastRunAtTime) {
+			return 0
 		}
+		return 7
 	}
-	return s.roundToMidnight(delta).AddDate(0, 0, int(j.interval)).Add(j.atTime)
+
+	return daysToWeekday
 }
 
-func (s *Scheduler) rescheduleDuration(j *Job, delta time.Time) time.Time {
-	if j.neverRan() && j.atTime != 0 { // ugly. in order to avoid this we could prohibit setting .At() and allowing only .StartAt() when dealing with Duration types
-		atTime := time.Date(delta.Year(), delta.Month(), delta.Day(), 0, 0, 0, 0, s.loc).Add(j.atTime)
-		if delta.Before(atTime) || delta.Equal(atTime) {
-			return s.roundToMidnight(delta).Add(j.atTime)
+func (s *Scheduler) calculateDays(job *Job, lastRun time.Time) time.Duration {
+	if job.interval == 1 {
+		lastRunDayPlusJobAtTime := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day(), 0, 0, 0, 0, s.loc).Add(job.atTime)
+		if shouldRunToday(lastRun, lastRunDayPlusJobAtTime) {
+			return s.until(lastRun, s.roundToMidnight(lastRun).Add(job.atTime))
 		}
 	}
 
-	var periodDuration time.Duration
-	switch j.unit {
-	case seconds:
-		periodDuration = time.Duration(j.interval) * time.Second
-	case minutes:
-		periodDuration = time.Duration(j.interval) * time.Minute
-	case hours:
-		periodDuration = time.Duration(j.interval) * time.Hour
-	}
-	return delta.Add(periodDuration)
+	nextRunAtTime := s.roundToMidnight(lastRun).Add(job.atTime).AddDate(0, 0, int(job.interval)).In(s.loc)
+	return s.until(lastRun, nextRunAtTime)
 }
 
-func (s *Scheduler) calculateWeekdayDifference(delta time.Time, j *Job) int {
-	daysToWeekday := remainingDaysToWeekday(delta.Weekday(), *j.scheduledWeekday)
-
-	if j.interval > 1 {
-		return daysToWeekday + int(j.interval-1)*7 // minus a week since to compensate daysToWeekday
-	}
+func (s Scheduler) until(from time.Time, until time.Time) time.Duration {
+	return until.Sub(from)
+}
 
-	if daysToWeekday > 0 { // within the next following days, but not today
-		return daysToWeekday
-	}
+func shouldRunToday(lastRun time.Time, atTime time.Time) bool {
+	return lastRun.Before(atTime)
+}
 
-	// following paths are on same day
-	if j.atTime.Seconds() == 0 && j.neverRan() { // .At() not set, run today
-		return 0
+func (s *Scheduler) calculateDuration(job *Job) time.Duration {
+	lastRun := job.lastRun
+	if job.neverRan() && shouldRunAtSpecificTime(job) { // ugly. in order to avoid this we could prohibit setting .At() and allowing only .StartAt() when dealing with Duration types
+		atTime := time.Date(lastRun.Year(), lastRun.Month(), lastRun.Day(), 0, 0, 0, 0, s.loc).Add(job.atTime)
+		if lastRun.Before(atTime) || lastRun.Equal(atTime) {
+			return time.Until(s.roundToMidnight(lastRun).Add(job.atTime))
+		}
 	}
 
-	atJobTime := time.Date(delta.Year(), delta.Month(), delta.Day(), 0, 0, 0, 0, s.loc).Add(j.atTime)
-	if delta.Before(atJobTime) || delta.Equal(atJobTime) { // .At() set and should run today
-		return 0
+	interval := job.interval
+	switch job.unit {
+	case seconds:
+		return time.Duration(interval) * time.Second
+	case minutes:
+		return time.Duration(interval) * time.Minute
+	default:
+		return time.Duration(interval) * time.Hour
 	}
+}
 
-	return 7
+func shouldRunAtSpecificTime(job *Job) bool {
+	return job.atTime != 0
 }
 
 func remainingDaysToWeekday(from time.Weekday, to time.Weekday) int {
@@ -265,9 +287,7 @@ func (s *Scheduler) runAndReschedule(job *Job) error {
 }
 
 func (s *Scheduler) run(job *Job) error {
-	job.lastRun = s.time.Now(s.loc)
 	go job.run()
-
 	return nil
 }
 


Test patch:
diff --git a/scheduler_test.go b/scheduler_test.go
index b961597..64096f3 100644
--- a/scheduler_test.go
+++ b/scheduler_test.go
@@ -8,6 +8,26 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+type fakeTime struct {
+	onNow func(location *time.Location) time.Time
+}
+
+func (f fakeTime) Now(loc *time.Location) time.Time {
+	return f.onNow(loc)
+}
+
+func (f fakeTime) Unix(i int64, i2 int64) time.Time {
+	panic("implement me")
+}
+
+func (f fakeTime) Sleep(duration time.Duration) {
+	panic("implement me")
+}
+
+func (f fakeTime) NewTicker(duration time.Duration) *time.Ticker {
+	panic("implement me")
+}
+
 func task() {
 	fmt.Println("I am a running job.")
 }
@@ -33,7 +53,7 @@ func TestExecutionSeconds(t *testing.T) {
 	numberOfIterations := 2
 
 	sched.Every(2).Seconds().Do(func() {
-		executionTimes = append(executionTimes, time.Now().Unix())
+		executionTimes = append(executionTimes, time.Now().UTC().Unix())
 		if len(executionTimes) >= numberOfIterations {
 			jobDone <- true
 		}
@@ -362,233 +382,404 @@ func TestScheduler_StartAt(t *testing.T) {
 	assert.Equal(t, now.Add(time.Second*3).Second(), nextRun.Second())
 }
 
-func TestScheduler_FirstSchedule(t *testing.T) {
+func TestScheduler_CalculateNextRun(t *testing.T) {
 	day := time.Hour * 24
-	janFirst2020 := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
-	monday := time.Date(2020, time.January, 6, 0, 0, 0, 0, time.UTC)
-	tuesday := monday.AddDate(0, 0, 1)
-	wednesday := monday.AddDate(0, 0, 2)
+	januaryFirst2020At := func(hour, minute, second int) time.Time {
+		return time.Date(2020, time.January, 1, hour, minute, second, 0, time.UTC)
+	}
+	mondayAt := func(hour, minute, second int) time.Time {
+		return time.Date(2020, time.January, 6, hour, minute, second, 0, time.UTC)
+	}
 
-	fakeTime := fakeTime{}
-	sched := NewScheduler(time.UTC)
-	sched.time = &fakeTime
 	var tests = []struct {
-		name             string
-		job              *Job
-		startRunningTime time.Time
-		wantNextSchedule time.Time
+		name                 string
+		job                  Job
+		wantTimeUntilNextRun time.Duration
 	}{
 		// SECONDS
 		{
-			name:             "every second test",
-			job:              getJob(sched.Every(1).Second().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: janFirst2020.Add(time.Second),
+			name: "every second test",
+			job: Job{
+				interval: 1,
+				unit:     seconds,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: _getSeconds(1),
 		},
 		{
-			name:             "every 62 seconds test",
-			job:              getJob(sched.Every(62).Seconds().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: janFirst2020.Add(62 * time.Second),
+			name: "every 62 seconds test",
+			job: Job{
+				interval: 62,
+				unit:     seconds,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: _getSeconds(62),
 		},
 		// MINUTES
 		{
-			name:             "every minute test",
-			job:              getJob(sched.Every(1).Minute().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: janFirst2020.Add(1 * time.Minute),
+			name: "every minute test",
+			job: Job{
+				interval: 1,
+				unit:     minutes,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: _getMinutes(1),
 		},
 		{
-			name:             "every 62 minutes test",
-			job:              getJob(sched.Every(62).Minutes().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: janFirst2020.Add(62 * time.Minute),
+			name: "every 62 minutes test",
+			job: Job{
+				interval: 62,
+				unit:     minutes,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: _getMinutes(62),
 		},
 		// HOURS
 		{
-			name:             "every hour test",
-			job:              getJob(sched.Every(1).Hour().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: janFirst2020.Add(1 * time.Hour),
+			name: "every hour test",
+			job: Job{
+				interval: 1,
+				unit:     hours,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: _getHours(1),
 		},
 		{
-			name:             "every 25 hours test",
-			job:              getJob(sched.Every(25).Hours().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: janFirst2020.Add(25 * time.Hour),
+			name: "every 25 hours test",
+			job: Job{
+				interval: 25,
+				unit:     hours,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: _getHours(25),
 		},
 		// DAYS
 		{
-			name:             "every day at midnight",
-			job:              getJob(sched.Every(1).Day().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: janFirst2020.Add(1 * day),
+			name: "every day at midnight",
+			job: Job{
+				interval: 1,
+				unit:     days,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 1 * day,
+		},
+		{
+			name: "every day at 09:30AM with scheduler starting before 09:30AM should run at same day at time",
+			job: Job{
+				interval: 1,
+				unit:     days,
+				atTime:   _getHours(9) + _getMinutes(30),
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: _getHours(9) + _getMinutes(30),
+		},
+		{
+			name: "every day at 09:30AM which just ran should run tomorrow at 09:30AM",
+			job: Job{
+				interval: 1,
+				unit:     days,
+				atTime:   _getHours(9) + _getMinutes(30),
+				lastRun:  januaryFirst2020At(9, 30, 0),
+			},
+			wantTimeUntilNextRun: 1 * day,
+		},
+		{
+			name: "every 31 days at midnight should run 31 days later",
+			job: Job{
+				interval: 31,
+				unit:     days,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 31 * day,
+		},
+		{
+			name: "daily job just ran at 8:30AM and should be scheduled for next day's 8:30AM",
+			job: Job{
+				interval: 1,
+				unit:     days,
+				atTime:   8*time.Hour + 30*time.Minute,
+				lastRun:  januaryFirst2020At(8, 30, 0),
+			},
+			wantTimeUntilNextRun: 24 * time.Hour,
 		},
 		{
-			name:             "every day at 09:30AM with scheduler starting before 09:30AM should run at same day at time",
-			job:              getJob(sched.Every(1).Day().At("09:30").Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.January, 1, 9, 30, 0, 0, time.UTC),
+			name: "daily job just ran at 5:30AM and should be scheduled for today at 8:30AM",
+			job: Job{
+				interval: 1,
+				unit:     days,
+				atTime:   8*time.Hour + 30*time.Minute,
+				lastRun:  januaryFirst2020At(5, 30, 0),
+			},
+			wantTimeUntilNextRun: 3 * time.Hour,
 		},
 		{
-			name:             "every day at 09:30AM with scheduler starting after 09:30AM should run tomorrow at time",
-			job:              getJob(sched.Every(1).Day().At("09:30").Do),
-			startRunningTime: janFirst2020.Add(10 * time.Hour),
-			wantNextSchedule: time.Date(2020, time.January, 2, 9, 30, 0, 0, time.UTC),
+			name: "job runs every 2 days, just ran at 5:30AM and should be scheduled for 2 days at 8:30AM",
+			job: Job{
+				interval: 2,
+				unit:     days,
+				atTime:   8*time.Hour + 30*time.Minute,
+				lastRun:  januaryFirst2020At(5, 30, 0),
+			},
+			wantTimeUntilNextRun: (2 * day) + 3*time.Hour,
 		},
 		{
-			name:             "every 31 days at midnight should run 31 days later",
-			job:              getJob(sched.Every(31).Days().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.February, 1, 0, 0, 0, 0, time.UTC),
+			name: "job runs every 2 days, just ran at 8:30AM and should be scheduled for 2 days at 8:30AM",
+			job: Job{
+				interval: 2,
+				unit:     days,
+				atTime:   8*time.Hour + 30*time.Minute,
+				lastRun:  januaryFirst2020At(8, 30, 0),
+			},
+			wantTimeUntilNextRun: 2 * day,
 		},
-		// WEEKS
+		//// WEEKS
 		{
-			name:             "every week should run in 7 days",
-			job:              getJob(sched.Every(1).Week().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.January, 8, 0, 0, 0, 0, time.UTC),
+			name: "every week should run in 7 days",
+			job: Job{
+				interval: 1,
+				unit:     weeks,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 7 * day,
 		},
 		{
-			name:             "every week at 09:30AM should run in 7 days at 09:30AM",
-			job:              getJob(sched.Every(1).Week().At("09:30").Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.January, 8, 9, 30, 0, 0, time.UTC),
+			name: "every week with .At time rule should run respect .At time rule",
+			job: Job{
+				interval: 1,
+				atTime:   _getHours(9) + _getMinutes(30),
+				unit:     weeks,
+				lastRun:  januaryFirst2020At(9, 30, 0),
+			},
+			wantTimeUntilNextRun: 7 * day,
 		},
 		{
-			name:             "every two weeks at 09:30AM should run in 14 days at 09:30AM",
-			job:              getJob(sched.Every(2).Weeks().At("09:30").Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.January, 15, 9, 30, 0, 0, time.UTC),
+			name: "every two weeks at 09:30AM should run in 14 days at 09:30AM",
+			job: Job{
+				interval: 2,
+				unit:     weeks,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 14 * day,
 		},
 		{
-			name:             "every 31 weeks at midnight should run in 217 days (2020 was a leap year)",
-			job:              getJob(sched.Every(31).Weeks().Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.August, 5, 0, 0, 0, 0, time.UTC),
+			name: "every 31 weeks ran at jan 1st at midnight should run at August 5, 2020",
+			job: Job{
+				interval: 31,
+				unit:     weeks,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 31 * 7 * day,
 		},
 		// MONTHS
 		{
-			name:             "every month at first day starting at first day should run at same day",
-			job:              getJob(sched.Every(1).Month(1).Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC),
+			name: "every month in a 31 days month should be scheduled for 31 days ahead",
+			job: Job{
+				interval: 1,
+				unit:     months,
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 31 * day,
+		},
+		{
+			name: "every month in a 30 days month should be scheduled for 30 days ahead",
+			job: Job{
+				interval: 1,
+				unit:     months,
+				lastRun:  time.Date(2020, time.April, 1, 0, 0, 0, 0, time.UTC),
+			},
+			wantTimeUntilNextRun: 30 * day,
+		},
+		{
+			name: "every month at february on leap year should count 29 days",
+			job: Job{
+				interval: 1,
+				unit:     months,
+				lastRun:  time.Date(2020, time.February, 1, 0, 0, 0, 0, time.UTC),
+			},
+			wantTimeUntilNextRun: 29 * day,
 		},
 		{
-			name:             "every month at first day at time, but started late, should run next month",
-			job:              getJob(sched.Every(1).Month(1).At("09:30").Do),
-			startRunningTime: janFirst2020.Add(10 * time.Hour),
-			wantNextSchedule: time.Date(2020, time.February, 1, 9, 30, 0, 0, time.UTC),
+			name: "every month at february on non leap year should count 28 days",
+			job: Job{
+				interval: 1,
+				unit:     months,
+				lastRun:  time.Date(2019, time.February, 1, 0, 0, 0, 0, time.UTC),
+			},
+			wantTimeUntilNextRun: 28 * day,
 		},
 		{
-			name:             "every month at day 2, and started in day 1, day should run day 2 same month",
-			job:              getJob(sched.Every(1).Month(2).Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.January, 2, 0, 0, 0, 0, time.UTC),
+			name: "every month at first day at time should run next month + at time",
+			job: Job{
+				interval: 1,
+				unit:     months,
+				atTime:   _getHours(9) + _getMinutes(30),
+				lastRun:  januaryFirst2020At(9, 30, 0),
+			},
+			wantTimeUntilNextRun: 31*day + _getHours(9) + _getMinutes(30),
 		},
 		{
-			name:             "every month at day 1, but started on the day 8, should run next month at day 1",
-			job:              getJob(sched.Every(1).Month(1).Do),
-			startRunningTime: time.Date(2020, time.January, 8, 0, 0, 0, 0, time.UTC),
-			wantNextSchedule: time.Date(2020, time.February, 1, 0, 0, 0, 0, time.UTC),
+			name: "every month at day should consider at days",
+			job: Job{
+				interval:      1,
+				unit:          months,
+				dayOfTheMonth: 2,
+				lastRun:       januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 1 * day,
 		},
 		{
-			name:             "every 2 months at day 1, starting at day 1, should run in 2 months",
-			job:              getJob(sched.Every(2).Months(1).Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.March, 1, 0, 0, 0, 0, time.UTC),
+			name: "every month at day should consider at hours",
+			job: Job{
+				interval: 1,
+				unit:     months,
+				atTime:   _getHours(9) + _getMinutes(30),
+				lastRun:  januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 31*day + _getHours(9) + _getMinutes(30),
 		},
 		{
-			name:             "every 2 months at day 2, starting at day 1, should run in 2 months",
-			job:              getJob(sched.Every(2).Months(2).Do),
-			startRunningTime: janFirst2020,
-			wantNextSchedule: time.Date(2020, time.March, 2, 0, 0, 0, 0, time.UTC),
+			name: "every month on the first day, but started on january 8th, should run February 1st",
+			job: Job{
+				interval:      1,
+				unit:          months,
+				dayOfTheMonth: 1,
+				lastRun:       januaryFirst2020At(0, 0, 0).AddDate(0, 0, 7),
+			},
+			wantTimeUntilNextRun: 24 * day,
 		},
 		{
-			name:             "every 2 months at day 1, starting at day 2, should run 2 months later at day 1",
-			job:              getJob(sched.Every(2).Months(1).Do),
-			startRunningTime: time.Date(2020, time.January, 2, 0, 0, 0, 0, time.UTC),
-			wantNextSchedule: time.Date(2020, time.March, 1, 0, 0, 0, 0, time.UTC),
+			name: "every 2 months at day 1, starting at day 1, should run in 2 months",
+			job: Job{
+				interval:      2,
+				unit:          months,
+				dayOfTheMonth: 1,
+				lastRun:       januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 31*day + 29*day, // 2020 january and february
 		},
 		{
-			name:             "every 13 months at day 1, starting at day 2 run in 13 months",
-			job:              getJob(sched.Every(13).Months(1).Do),
-			startRunningTime: time.Date(2020, time.January, 2, 0, 0, 0, 0, time.UTC),
-			wantNextSchedule: time.Date(2021, time.February, 1, 0, 0, 0, 0, time.UTC),
+			name: "every 2 months at day 2, starting at day 1, should run in 2 months + 1 day",
+			job: Job{
+				interval:      2,
+				unit:          months,
+				dayOfTheMonth: 2,
+				lastRun:       januaryFirst2020At(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 31*day + 29*day + 1*day, // 2020 january and february
 		},
-		// WEEKDAYS
 		{
-			name:             "every tuesday starting on a monday should run this tuesday",
-			job:              getJob(sched.Every(1).Tuesday().Do),
-			startRunningTime: monday,
-			wantNextSchedule: tuesday,
+			name: "every 2 months at day 1, starting at day 2, should run in 2 months - 1 day",
+			job: Job{
+				interval:      2,
+				unit:          months,
+				dayOfTheMonth: 1,
+				lastRun:       januaryFirst2020At(0, 0, 0).AddDate(0, 0, 1),
+			},
+			wantTimeUntilNextRun: 30*day + 29*day, // 2020 january and february
 		},
 		{
-			name:             "every tuesday starting on tuesday a should run on same tuesday",
-			job:              getJob(sched.Every(1).Tuesday().Do),
-			startRunningTime: tuesday,
-			wantNextSchedule: tuesday,
+			name: "every 13 months at day 1, starting at day 2 run in 13 months - 1 day",
+			job: Job{
+				interval:      13,
+				unit:          months,
+				dayOfTheMonth: 1,
+				lastRun:       januaryFirst2020At(0, 0, 0).AddDate(0, 0, 1),
+			},
+			wantTimeUntilNextRun: januaryFirst2020At(0, 0, 0).AddDate(0, 13, -1).Sub(januaryFirst2020At(0, 0, 0)),
 		},
+		//// WEEKDAYS
 		{
-			name:             "every 2 tuesdays starting on a tuesday should run next tuesday",
-			job:              getJob(sched.Every(2).Tuesday().Do),
-			startRunningTime: tuesday,
-			wantNextSchedule: tuesday.AddDate(0, 0, 7),
+			name: "every weekday starting on one day before it should run this weekday",
+			job: Job{
+				interval:         1,
+				unit:             weeks,
+				scheduledWeekday: _tuesdayWeekday(),
+				lastRun:          mondayAt(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 1 * day,
 		},
 		{
-			name:             "every tuesday starting on a wednesday should run next tuesday",
-			job:              getJob(sched.Every(1).Tuesday().Do),
-			startRunningTime: wednesday,
-			wantNextSchedule: tuesday.AddDate(0, 0, 7),
+			name: "every weekday starting on same weekday should run on same immediately",
+			job: Job{
+				interval:         1,
+				unit:             weeks,
+				scheduledWeekday: _tuesdayWeekday(),
+				lastRun:          mondayAt(0, 0, 0).AddDate(0, 0, 1),
+			},
+			wantTimeUntilNextRun: 0,
 		},
 		{
-			name:             "starting on a monday, every monday at time to happen should run at same day at time",
-			job:              getJob(sched.Every(1).Monday().At("09:00").Do),
-			startRunningTime: monday,
-			wantNextSchedule: monday.Add(9 * time.Hour),
+			name: "every 2 weekdays counting this week's weekday should run next weekday",
+			job: Job{
+				interval:         2,
+				unit:             weeks,
+				scheduledWeekday: _tuesdayWeekday(),
+				lastRun:          mondayAt(0, 0, 0),
+			},
+			wantTimeUntilNextRun: 8 * day,
 		},
 		{
-			name:             "starting on a monday, every monday at time that already passed should run at next week at time",
-			job:              getJob(sched.Every(1).Monday().At("09:00").Do),
-			startRunningTime: monday.Add(10 * time.Hour),
-			wantNextSchedule: monday.AddDate(0, 0, 7).Add(9 * time.Hour),
+			name: "every weekday starting on one day after should count days remaning",
+			job: Job{
+				interval:         1,
+				unit:             weeks,
+				scheduledWeekday: _tuesdayWeekday(),
+				lastRun:          mondayAt(0, 0, 0).AddDate(0, 0, 2),
+			},
+			wantTimeUntilNextRun: 6 * day,
+		},
+		{
+			name: "every weekday starting before jobs .At() time should run at same day at time",
+			job: Job{
+				interval:         1,
+				unit:             weeks,
+				atTime:           _getHours(9) + _getMinutes(30),
+				scheduledWeekday: _tuesdayWeekday(),
+				lastRun:          mondayAt(0, 0, 0).AddDate(0, 0, 1),
+			},
+			wantTimeUntilNextRun: _getHours(9) + _getMinutes(30),
+		},
+		{
+			name: "every weekday starting at same day at time that already passed should run at next week at time",
+			job: Job{
+				interval:         1,
+				unit:             weeks,
+				atTime:           _getHours(9) + _getMinutes(30),
+				scheduledWeekday: _tuesdayWeekday(),
+				lastRun:          mondayAt(10, 30, 0).AddDate(0, 0, 1),
+			},
+			wantTimeUntilNextRun: 6*day + _getHours(23) + _getMinutes(0),
 		},
 	}
 
-	for i, tt := range tests {
-		fakeTime.onNow = func(location *time.Location) time.Time { // scheduler started
-			return tests[i].startRunningTime
-		}
-
-		job := tt.job
-		sched.scheduleNextRun(job)
-		assert.Equal(t, tt.wantNextSchedule, job.nextRun, tt.name)
+	for i := range tests {
+		t.Run(tests[i].name, func(t *testing.T) {
+			sched := NewScheduler(time.UTC)
+			got := sched.durationToNextRun(&tests[i].job)
+			assert.Equalf(t, tests[i].wantTimeUntilNextRun, got, fmt.Sprintf("expected %s / got %s", tests[i].wantTimeUntilNextRun.String(), got.String()))
+		})
 	}
 }
 
-func getJob(fn func(interface{}, ...interface{}) (*Job, error)) *Job {
-	j, _ := fn(func() {})
-	return j
+// helper test method
+func _tuesdayWeekday() *time.Weekday {
+	tuesday := time.Tuesday
+	return &tuesday
 }
 
-type fakeTime struct {
-	onNow func(location *time.Location) time.Time
-}
-
-func (f fakeTime) Now(loc *time.Location) time.Time {
-	return f.onNow(loc)
-}
-
-func (f fakeTime) Unix(i int64, i2 int64) time.Time {
-	panic("implement me")
+// helper test method
+func _getSeconds(i int) time.Duration {
+	return time.Duration(i) * time.Second
 }
 
-func (f fakeTime) Sleep(duration time.Duration) {
-	panic("implement me")
+// helper test method
+func _getHours(i int) time.Duration {
+	return time.Duration(i) * time.Hour
 }
 
-func (f fakeTime) NewTicker(duration time.Duration) *time.Ticker {
-	panic("implement me")
+// helper test method
+func _getMinutes(i int) time.Duration {
+	return time.Duration(i) * time.Minute
 }
 
 func TestScheduler_Do(t *testing.T) {