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
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]
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]