Skip to content

Instantly share code, notes, and snippets.

@rossta
Last active March 12, 2018 21:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rossta/f6ac56fe5f959b292b618fe74d906caf to your computer and use it in GitHub Desktop.
Save rossta/f6ac56fe5f959b292b618fe74d906caf to your computer and use it in GitHub Desktop.
Explaining montrose.rb

Montrose

Montrose is a Ruby gem I wrote to specify and enumerate recurring events in Ruby. The source is hosted on Github.

The why: Dealing with recurring events is hard. Montrose provides a simple interface for specifying and enumerating recurring events as Time objects for Ruby applications.

More specifically, this project intends to:

  • model recurring events in Ruby
  • embrace Ruby idioms
  • be serializable

Usage

Montrose allows you to easily create "recurrence objects" through chaining:

# Every Monday at 10:30am
Montrose.weekly.on(:monday).at("10:30 am")
=> #<Montrose::Recurrence...>

Each chained recurrence returns a new object so they can be composed and merged. In both examples below, recurrence r4 represents 'every week on Tuesday and Thursday at noon for four occurrences'.

# Example 1 - building recurrence in succession
r1 = Montrose.every(:week)
r2 = r1.on([:tuesday, :thursday])
r3 = r2.at("12 pm")
r4 = r3.total(4)

# Example 2 - merging distinct recurrences
r1 = Montrose.every(:week)
r2 = Montrose.on([:tuesday, :thursday])
r3 = Montrose.at("12 pm")
r4 = r1.merge(r2).merge(r3).total(4)

Most recurrence methods accept additional options if you favor the hash-syntax:

Montrose.r(every: :week, on: :monday, at: "10:30 am")
=> #<Montrose::Recurrence...>

Montrose recurrences are themselves enumerable:

# Every month starting a year from now on Friday the 13th for 5 occurrences
r = Montrose.monthly.starting(1.year.from_now).on(friday: 13).repeat(5)

r.map(&:to_date)
=> [Fri, 13 Oct 2017,
Fri, 13 Apr 2018,
Fri, 13 Jul 2018,
Fri, 13 Sep 2019,
Fri, 13 Dec 2019]

Design

The chaining mechanism is inspired by the HTTP.rb gem. The public chainable methods are described in Montrose::Chainable which is used to extend the top-level Montrose namespace. Methods in Chainable delegate to the branch method by merging in a new set of options to a new recurrence.

    # Create a hourly recurrence.
    #
    # @param options [Hash] additional recurrence options
    #
    # @example
    #   Montrose.hourly
    #   Montrose.hourly(interval: 2) #=> every 2 hours
    #   Montrose.hourly(starts: 3.days.from_now)
    #   Montrose.hourly(until: 10.days.from_now)
    #   Montrose.hourly(total: 5)
    #   Montrose.hourly(except: Date.tomorrow)
    #
    # @return [Montrose::Recurrence]
    #
    def hourly(options = {})
      branch options.merge(every: :hour)
    end
    
    # @private
    def branch(options)
      Montrose::Recurrence.new(options)
    end

The Montrose::Recurrence class implements the Enumerable interface by defining each in terms of an Enumerator that can dynamically generate events.

    # Iterate over the events of a recurrence. Along with the Enumerable
    # module, this makes Montrose occurrences enumerable like other Ruby
    # collections
    #
    # @example Iterate over a finite recurrence
    #   recurrence = Montrose.recurrence(every: :day, until: 1.year.from_now)
    #   recurrence.each do |event|
    #     puts event
    #   end
    #
    # @example Iterate over an infinite recurrence
    #   recurrence = Montrose.recurrence(every: :day)
    #   recurrence.lazy.each do |event|
    #     puts event
    #   end
    #
    # @return [Enumerator] an enumerator of recurrence timestamps
    def each(&block)
      event_enum.each(&block)
    end

The event_enum method is where the magic happens. It builds up a "stack" of rules from the given recurrence options, generates timestamps with a "clock", which tries to guess matching timestamps, uses the rule stack to filter matching timestamps, and yields those timestamps to the caller.

    def event_enum
      opts = Options.merge(@default_options)
      stack = Stack.new(opts)
      clock = Clock.new(opts)

      Enumerator.new do |yielder|
        loop do
          stack.advance(clock.tick) do |time|
            yielder << time
          end or break
        end
      end
    end

The rule stack consists of rule classes that are built to match a given timestamp based on its attributes:

class Stack
    def self.build(opts = {})
      [
        Frequency,
        Rule::After,
        Rule::Until,
        Rule::Between,
        Rule::Except,
        Rule::Total,
        Rule::TimeOfDay,
        Rule::HourOfDay,
        Rule::NthDayOfMonth,
        Rule::NthDayOfYear,
        Rule::DayOfWeek,
        Rule::DayOfMonth,
        Rule::DayOfYear,
        Rule::WeekOfYear,
        Rule::MonthOfYear
      ].map { |r| r.from_options(opts) }.compact
    end
...
end

The Stack#advance method operates passes a given timestamp to its rule set and updates internal state to communicate with the enumerator.

    # Given a time instance, advances state of when all
    # recurrence rules on the stack match, and yielding
    # time to the block, otherwise, invokes break? on
    # non-matching rules.
    #
    # @param [Time] time - time instance candidate for recurrence
    #
    def advance(time)
      yes, no = @stack.partition { |rule| rule.include?(time) }

      if no.empty?
        yes.all? { |rule| rule.advance!(time) } or return false
        puts time if ENV["DEBUG"]
        yield time if block_given?
        true
      else
        no.any? { |rule| rule.continue?(time) }
      end
    end

The end result is that, conceptually, recurrences can represent infinite sequences. When we say simply "every day", there is no implied ending. It's therefore possible to create a recurrence that can enumerate forever, so use your Enumerable methods wisely.

# Every day starting now
r = Montrose.daily

# this expression will never complete, Ctrl-c!
r.map(&:to_date)

# use `lazy` enumerator to avoid eager enumeration
r.lazy.map(&:to_date).select { |d| d.mday > 25 }.take(5).to_a
=> [Fri, 26 Feb 2016,
Sat, 27 Feb 2016,
Sun, 28 Feb 2016,
Mon, 29 Feb 2016,
Sat, 26 Mar 2016]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment