Skip to content

Commit

Permalink
Add .MonthLastDay() to schedule a task at the end of each month (#250)
Browse files Browse the repository at this point in the history
* scheduler: add .MonthLastDay() to schedule a task at the end of a month. Fixes #224

* MonthLastDay: add examples in README.md and example_test

* scheduler: only allow -1 as single argument Month(s), add a test case to support this

* example_test: alphabetize ExampleScheduler_MonthLastDay

* scheduler: when scheduling on last day of month, take into account starting earlier than last day of the month and then schedule the job in the same month

Co-authored-by: John Roesler <[email protected]>
  • Loading branch information
mrngm and JohnRoesler authored Nov 12, 2021
1 parent 034e2dc commit 5dd4543
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 8 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ s.Every(5).Days().Do(func(){ ... })

s.Every(1).Month(1, 2, 3).Do(func(){ ... })

// Schedule each last day of the month
s.Every(1).MonthLastDay().Do(func(){ ... })

// Or each last day of every other month
s.Every(2).MonthLastDay().Do(func(){ ... })

// cron expressions supported
s.Cron("*/1 * * * *").Do(task) // every minute

Expand Down
7 changes: 7 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,13 @@ func ExampleScheduler_Month() {
_, _ = s.Month(1, 2).Every(1).Do(task)
}

func ExampleScheduler_MonthLastDay() {
s := gocron.NewScheduler(time.UTC)

_, _ = s.Every(1).MonthLastDay().Do(task)
_, _ = s.Every(2).MonthLastDay().Do(task)
}

func ExampleScheduler_Months() {
s := gocron.NewScheduler(time.UTC)

Expand Down
39 changes: 35 additions & 4 deletions scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ func (s *Scheduler) durationToNextRun(lastRun time.Time, job *Job) nextRun {
func (s *Scheduler) calculateMonths(job *Job, lastRun time.Time) nextRun {
lastRunRoundedMidnight := s.roundToMidnight(lastRun)

// Special case: the last day of the month
if len(job.daysOfTheMonth) == 1 && job.daysOfTheMonth[0] == -1 {
return calculateNextRunForLastDayOfMonth(s, job, lastRun)
}

if len(job.daysOfTheMonth) != 0 { // calculate days to job.daysOfTheMonth

nextRunDateMap := make(map[int]nextRun)
Expand All @@ -264,6 +269,22 @@ func (s *Scheduler) calculateMonths(job *Job, lastRun time.Time) nextRun {
return nextRun{duration: until(lastRunRoundedMidnight, next), dateTime: next}
}

func calculateNextRunForLastDayOfMonth(s *Scheduler, job *Job, lastRun time.Time) nextRun {
// Calculate the last day of the next month, by adding job.interval+1 months (i.e. the
// first day of the month after the next month), and subtracting one day, unless the
// last run occurred before the end of the month.
addMonth := job.interval
if testDate := lastRun.AddDate(0, 0, 1); testDate.Month() != lastRun.Month() {
// Our last run was on the last day of this month.
addMonth++
}
next := time.Date(lastRun.Year(), lastRun.Month(), 1, 0, 0, 0, 0, s.Location()).
Add(job.getAtTime()).
AddDate(0, addMonth, 0).
AddDate(0, 0, -1)
return nextRun{duration: until(lastRun, next), dateTime: next}
}

func calculateNextRunForMonth(s *Scheduler, job *Job, lastRun time.Time, dayOfMonth int) nextRun {

jobDay := time.Date(lastRun.Year(), lastRun.Month(), dayOfMonth, 0, 0, 0, 0, s.Location()).Add(job.getAtTime())
Expand Down Expand Up @@ -885,19 +906,26 @@ func (s *Scheduler) Month(daysOfMonth ...int) *Scheduler {
return s.Months(daysOfMonth...)
}

// MonthLastDay sets the unit with months at every last day of the month
func (s *Scheduler) MonthLastDay() *Scheduler {
return s.Months(-1)
}

// Months sets the unit with months
// Note: Only days 1 through 28 are allowed for monthly schedules
// Note: Multiple add same days of month cannot be allowed
// Note: -1 is a special value and can only occur as single argument
func (s *Scheduler) Months(daysOfTheMonth ...int) *Scheduler {
job := s.getCurrentJob()

if len(daysOfTheMonth) == 0 {
job.error = wrapOrError(job.error, ErrInvalidDayOfMonthEntry)
} else {

if job.daysOfTheMonth == nil {
job.daysOfTheMonth = make([]int, 0)
} else if len(daysOfTheMonth) == 1 {
dayOfMonth := daysOfTheMonth[0]
if dayOfMonth != -1 && (dayOfMonth < 1 || dayOfMonth > 28) {
job.error = wrapOrError(job.error, ErrInvalidDayOfMonthEntry)
}
} else {

repeatMap := make(map[int]int)
for _, dayOfMonth := range daysOfTheMonth {
Expand All @@ -922,6 +950,9 @@ func (s *Scheduler) Months(daysOfTheMonth ...int) *Scheduler {
}
}
}
if job.daysOfTheMonth == nil {
job.daysOfTheMonth = make([]int, 0)
}
job.daysOfTheMonth = append(job.daysOfTheMonth, daysOfTheMonth...)
job.startsImmediately = false
s.setUnit(months)
Expand Down
25 changes: 21 additions & 4 deletions scheduler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,9 @@ func TestScheduler_CalculateNextRun(t *testing.T) {
januaryFirst2020At := func(hour, minute, second int) time.Time {
return time.Date(2020, time.January, 1, hour, minute, second, 0, time.UTC)
}
januaryFirst2019At := func(hour, minute, second int) time.Time {
return time.Date(2019, 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)
}
Expand Down Expand Up @@ -751,6 +754,19 @@ func TestScheduler_CalculateNextRun(t *testing.T) {
{name: "every 2 months at day 2, starting at day 1, should run in 2 months + 1 day", job: &Job{interval: 2, unit: months, daysOfTheMonth: []int{2}, lastRun: januaryFirst2020At(0, 0, 0)}, wantTimeUntilNextRun: 31*day + 29*day + 1*day}, // 2020 january and february
{name: "every 2 months at day 1, starting at day 2, should run in 2 months - 1 day", job: &Job{interval: 2, unit: months, daysOfTheMonth: []int{1}, lastRun: januaryFirst2020At(0, 0, 0).AddDate(0, 0, 1)}, wantTimeUntilNextRun: 30*day + 29*day}, // 2020 january and february
{name: "every 13 months at day 1, starting at day 2 run in 13 months - 1 day", job: &Job{interval: 13, unit: months, daysOfTheMonth: []int{1}, lastRun: januaryFirst2020At(0, 0, 0).AddDate(0, 0, 1)}, wantTimeUntilNextRun: januaryFirst2020At(0, 0, 0).AddDate(0, 13, -1).Sub(januaryFirst2020At(0, 0, 0))},
{name: "every last day of the month started on leap year february should run on march 31", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: time.Date(2020, time.February, 29, 0, 0, 0, 0, time.UTC)}, wantTimeUntilNextRun: 31 * day},
{name: "every last day of the month started on non-leap year february should run on march 31", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: time.Date(2019, time.February, 28, 0, 0, 0, 0, time.UTC)}, wantTimeUntilNextRun: 31 * day},
{name: "every last day of 2 months started on leap year february should run on april 30", job: &Job{interval: 2, unit: months, daysOfTheMonth: []int{-1}, lastRun: time.Date(2020, time.February, 29, 0, 0, 0, 0, time.UTC)}, wantTimeUntilNextRun: 31*day + 30*day},
{name: "every last day of 2 months started on non-leap year february should run on april 30", job: &Job{interval: 2, unit: months, daysOfTheMonth: []int{-1}, lastRun: time.Date(2019, time.February, 28, 0, 0, 0, 0, time.UTC)}, wantTimeUntilNextRun: 31*day + 30*day},
{name: "every last day of the month started on january 1 in leap year should run on january 31", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2020At(0, 0, 0)}, wantTimeUntilNextRun: 30 * day},
{name: "every last day of the month started on january 1 in non-leap year should run on january 31", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2019At(0, 0, 0)}, wantTimeUntilNextRun: 30 * day},
{name: "every last day of the month started on january 30 in leap year should run on january 31", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2020At(0, 0, 0).AddDate(0, 0, 29)}, wantTimeUntilNextRun: 1 * day},
{name: "every last day of the month started on january 30 in non-leap year should run on january 31", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2019At(0, 0, 0).AddDate(0, 0, 29)}, wantTimeUntilNextRun: 1 * day},
{name: "every last day of the month started on january 31 in leap year should run on february 29", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2020At(0, 0, 0).AddDate(0, 0, 30)}, wantTimeUntilNextRun: 29 * day},
{name: "every last day of the month started on january 31 in non-leap year should run on february 28", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2019At(0, 0, 0).AddDate(0, 0, 30)}, wantTimeUntilNextRun: 28 * day},
{name: "every last day of the month started on december 31 should run on january 31 of the next year", job: &Job{interval: 1, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2019At(0, 0, 0).AddDate(0, 0, -1)}, wantTimeUntilNextRun: 31 * day},
{name: "every last day of 2 months started on december 31, 2018 should run on february 28, 2019", job: &Job{interval: 2, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2019At(0, 0, 0).AddDate(0, 0, -1)}, wantTimeUntilNextRun: 31*day + 28*day},
{name: "every last day of 2 months started on december 31, 2019 should run on february 29, 2020", job: &Job{interval: 2, unit: months, daysOfTheMonth: []int{-1}, lastRun: januaryFirst2020At(0, 0, 0).AddDate(0, 0, -1)}, wantTimeUntilNextRun: 31*day + 29*day},
//// WEEKDAYS
{name: "every weekday starting on one day before it should run this weekday", job: &Job{interval: 1, unit: weeks, scheduledWeekdays: []time.Weekday{*_tuesdayWeekday()}, lastRun: mondayAt(0, 0, 0)}, wantTimeUntilNextRun: 1 * day},
{name: "every weekday starting on same weekday should run on same immediately", job: &Job{interval: 1, unit: weeks, scheduledWeekdays: []time.Weekday{*_tuesdayWeekday()}, lastRun: mondayAt(0, 0, 0).AddDate(0, 0, 1)}, wantTimeUntilNextRun: 0},
Expand Down Expand Up @@ -925,16 +941,17 @@ func TestRunJobsWithLimit(t *testing.T) {
func TestCalculateMonthsError(t *testing.T) {
testCases := []struct {
desc string
dayOfMonth int
dayOfMonth []int
}{
{"invalid -1", -1},
{"invalid 29", 29},
// -1 is now interpreted as "last day of the month"
{"invalid 29", []int{29}},
{"invalid -1 in list", []int{27, -1}},
}

for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
s := NewScheduler(time.UTC)
job, err := s.Every(1).Month(tc.dayOfMonth).Do(func() {
job, err := s.Every(1).Month(tc.dayOfMonth...).Do(func() {
fmt.Println("hello task")
})
require.Error(t, err)
Expand Down

0 comments on commit 5dd4543

Please sign in to comment.