Skip to content

Commit

Permalink
Add additional options for mday
Browse files Browse the repository at this point in the history
Adds new options that can be supplied when creating an `mday` recurrence.
This is backwards compatible with the existing API, but now allows for
overriding specific months, or specifying a fallback if a month does not
meet the criteria.

```
Montrose.every(:month, mday: { default: 30, fallback: -1 })
Montrose.every(:month, mday: { default: 30, february: 28, march: 29 })
Montrose.every(:month, mday: { default: 30, [:february, :march] => 29, fallback: -1)
```
  • Loading branch information
Adrian Hooper committed Jun 21, 2024
1 parent af7a3fd commit 4e5b014
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 21 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ Montrose.every(:month, mday: [2, 15], total: 10)
# monthly on the first and last day of the month for 10 occurrences
Montrose.monthly(mday: [1, -1], total: 10)

# monthly on the 30th unless fewer days
Montrose.monthly(mday: { default: 30, fallback: -1 })

# monthly on the 25th except in december
Montrose.monthly(mday: { default: 25, december: 20 })

# every 18 months on the 10th thru 15th of the month for 10 occurrences
Montrose.every(18.months, total: 10, mday: 10..15)

Expand Down
37 changes: 35 additions & 2 deletions lib/montrose/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,8 @@ def day=(days)
@day = Day.parse(days)
end

def mday=(mdays)
@mday = MonthDay.parse(mdays)
def mday=(mday_arg)
@mday = decompose_mday_arg(mday_arg)
end

def yday=(ydays)
Expand Down Expand Up @@ -370,5 +370,38 @@ def end_of_day
def beginning_of_day
@beginning_of_day ||= time_of_day_parse(Time.now.beginning_of_day)
end

def decompose_mday_arg(mday_arg)
case mday_arg
when Hash
return nil unless mday_arg[:default].present?
{
default: MonthDay.parse(mday_arg[:default]),
overrides: flatten_mday_arg(mday_arg),
fallback: single_day(mday_arg[:fallback])
}
else
{default: MonthDay.parse(mday_arg), overrides: {}, fallback: nil}
end
end

def flatten_mday_arg(mday_arg)
mday_arg.except(:default, :overrides, :fallback).each_with_object({}) do |(months, day), result|
case months
when Array
months.each { |month| result[month] = single_day(day) }
else
result[months] = single_day(day)
end
end
end

def single_day(day_number)
return nil unless day_number
raise ConfigurationError, "mday override #{day_number} must be an integer" unless day_number.is_a?(Integer)
MonthDay.assert(day_number)

day_number
end
end
end
36 changes: 30 additions & 6 deletions lib/montrose/rule/day_of_month.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,46 @@ def self.apply_options(opts)

# Initializes rule
#
# @param [Array<Fixnum>] days - valid days of month, i.e. [1, 2, -1]
# @param [Hash] opts `mday` valid days of month, and `skip_months` options
#
def initialize(days)
@days = days
def initialize(opts)
@days = opts.fetch(:default)
@overrides = opts.fetch(:overrides, {})
@fallback = opts.fetch(:fallback, nil)
end

def include?(time)
@days.include?(time.mday) || included_from_end_of_month?(time)
return override?(time) if has_override?(month_name(time))

@days.include?(time.mday) || included_from_end_of_month?(time) || fallback?(time)
end

private

# matches days specified at negative numbers
def included_from_end_of_month?(time)
def included_from_end_of_month?(time, days = @days)
month_days = ::Montrose::Utils.days_in_month(time.month, time.year) # given by activesupport
@days.any? { |d| month_days + d + 1 == time.mday }
days.any? { |d| month_days + d + 1 == time.mday }
end

def has_override?(month)
@overrides.key?(month)
end

def override?(time)
return false if @overrides.blank?

time.day == @overrides[month_name(time)]
end

def month_name(time)
time.strftime("%B").downcase.to_sym
end

def fallback?(time)
return false unless @fallback.present?

time.day == @fallback || included_from_end_of_month?(time, [@fallback])
end
end
end
Expand Down
107 changes: 95 additions & 12 deletions spec/montrose/options_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -423,29 +423,57 @@
it "can be set" do
options[:mday] = [1, 20, 31]

_(options.mday).must_equal [1, 20, 31]
_(options[:mday]).must_equal [1, 20, 31]
_(options.mday).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil})
end

it "can be set to a hash" do
options[:mday] = {default: [1, 20, 31]}

_(options.mday).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [1, 20, 31], overrides: {}, fallback: nil})
end

it "casts to element to array" do
options[:mday] = 1

_(options.mday).must_equal [1]
_(options[:mday]).must_equal [1]
_(options.mday).must_equal({default: [1], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [1], overrides: {}, fallback: nil})
end

it "casts default element to array" do
options[:mday] = {default: 1}

_(options.mday).must_equal({default: [1], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [1], overrides: {}, fallback: nil})
end

it "allows negative numbers" do
options[:yday] = [-1]
options[:mday] = [-1]

_(options.yday).must_equal [-1]
_(options[:yday]).must_equal [-1]
_(options.mday).must_equal({default: [-1], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [-1], overrides: {}, fallback: nil})
end

it "allows default negative numbers" do
options[:mday] = {default: [-1]}

_(options.mday).must_equal({default: [-1], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [-1], overrides: {}, fallback: nil})
end

it "casts range to array" do
options[:mday] = 6..8

_(options.mday).must_equal [6, 7, 8]
_(options[:mday]).must_equal [6, 7, 8]
_(options.mday).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil})
end

it "casts default range to array" do
options[:mday] = {default: 6..8}

_(options.mday).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil})
_(options[:mday]).must_equal({default: [6, 7, 8], overrides: {}, fallback: nil})
end

it "casts nil to empty array" do
Expand All @@ -455,9 +483,64 @@
_(options[:day]).must_be_nil
end

it "casts default nil to empty array" do
options[:mday] = {default: nil}

_(options.mday).must_be_nil
_(options[:mday]).must_be_nil
end

it "raises for out of range" do
_(-> { options[:mday] = [1, 100] }).must_raise
end

it "raises for default out of range" do
_(-> { options[:mday] = {default: [1, 100]} }).must_raise
end

it "raises for array override" do
_(-> { options[:mday] = {default: 31, february: [28, 29]} }).must_raise
end

it "raises for array fallback" do
_(-> { options[:mday] = {default: 31, fallback: [28, 29]} }).must_raise
end

it "allows negative overrides" do
options[:mday] = {default: 31, february: -1}

_(options.mday).must_equal({default: [31], overrides: {february: -1}, fallback: nil})
_(options[:mday]).must_equal({default: [31], overrides: {february: -1}, fallback: nil})
end

it "allows negative fallback" do
options[:mday] = {default: 31, fallback: -1}

_(options.mday).must_equal({default: [31], overrides: {}, fallback: -1})
_(options[:mday]).must_equal({default: [31], overrides: {}, fallback: -1})
end

it "raises for override out of range" do
_(-> { options[:mday] = {default: 31, february: 100} }).must_raise
end

it "raises for fallback out of range" do
_(-> { options[:mday] = {default: 31, fallback: 100} }).must_raise
end

it "collects overrides" do
options[:mday] = {default: 31, september: 30, february: 28}

_(options.mday).must_equal({default: [31], overrides: {september: 30, february: 28}, fallback: nil})
_(options[:mday]).must_equal({default: [31], overrides: {september: 30, february: 28}, fallback: nil})
end

it "flattens overrides" do
options[:mday] = {:default => 31, [:september, :april, :june, :november] => 30, :february => 28}

_(options.mday).must_equal({default: [31], overrides: {september: 30, april: 30, june: 30, november: 30, february: 28}, fallback: nil})
_(options[:mday]).must_equal({default: [31], overrides: {september: 30, april: 30, june: 30, november: 30, february: 28}, fallback: nil})
end
end

describe "#yday" do
Expand Down Expand Up @@ -767,7 +850,7 @@
options[:on] = {friday: 13}

_(options[:day]).must_equal [5]
_(options[:mday]).must_equal [13]
_(options[:mday]).must_equal({default: [13], overrides: {}, fallback: nil})
_(options[:on]).must_equal(friday: 13)
end

Expand All @@ -776,15 +859,15 @@
options[:on] = {tuesday: 2..8}

_(options[:day]).must_equal [2]
_(options[:mday]).must_equal((2..8).to_a)
_(options[:mday]).must_equal({default: (2..8).to_a, overrides: {}, fallback: nil})
_(options[:month]).must_equal [11]
end

it "decompose month name => month day to month and mday" do
options[:on] = {january: 31}

_(options[:month]).must_equal [1]
_(options[:mday]).must_equal [31]
_(options[:mday]).must_equal({default: [31], overrides: {}, fallback: nil})
end

it { _(-> { options[:on] = -3 }).must_raise Montrose::ConfigurationError }
Expand Down
20 changes: 19 additions & 1 deletion spec/montrose/rule/day_of_month_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
require "spec_helper"

describe Montrose::Rule::DayOfMonth do
let(:rule) { Montrose::Rule::DayOfMonth.new([1, 10, -1]) }
let(:rule) { Montrose::Rule::DayOfMonth.new(default: [1, 10, -1], overrides: {}, fallback: nil) }
let(:fallback_rule) { Montrose::Rule::DayOfMonth.new(default: [31], overrides: {}, fallback: -1) }
let(:override_rule) { Montrose::Rule::DayOfMonth.new(default: [15], overrides: {february: 28, september: 30, november: 30, april: 30}, fallback: nil) }

describe "#include?" do
it { assert rule.include?(Time.local(2016, 1, 1)) }
Expand All @@ -14,6 +16,22 @@
it { refute rule.include?(Time.local(2015, 1, 2)) }
it { refute rule.include?(Time.local(2015, 1, 30)) }
it { refute rule.include?(Time.local(2015, 2, 27)) }

it { assert fallback_rule.include?(Time.local(2016, 1, 31)) }
it { assert fallback_rule.include?(Time.local(2015, 2, 28)) }
it { assert fallback_rule.include?(Time.local(2016, 2, 29)) }
it { assert fallback_rule.include?(Time.local(2016, 4, 30)) }
it { refute fallback_rule.include?(Time.local(2016, 1, 30)) }

it { assert override_rule.include?(Time.local(2016, 1, 15)) }
it { assert override_rule.include?(Time.local(2016, 2, 28)) }
it { assert override_rule.include?(Time.local(2016, 9, 30)) }
it { assert override_rule.include?(Time.local(2016, 11, 30)) }
it { assert override_rule.include?(Time.local(2016, 4, 30)) }
it { refute override_rule.include?(Time.local(2016, 9, 15)) }
it { refute override_rule.include?(Time.local(2016, 11, 15)) }
it { refute override_rule.include?(Time.local(2016, 4, 15)) }
it { refute override_rule.include?(Time.local(2016, 2, 15)) }
end

describe "#continue?" do
Expand Down

0 comments on commit 4e5b014

Please sign in to comment.