Montrose

Montrose is an easy-to-use library for defining recurring events in Ruby. It uses a simple chaining system for building enumerable recurrences, inspired heavily by the design principles of HTTP.rb and rule definitions available in Recurrence.
Installation
Add this line to your application's Gemfile:
gem "montrose"
And then execute:
$ bundle
Or install it yourself as:
$ gem install montrose
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
- support recent Rubies
- be reasonably performant
- serialize to yaml, hash, and ical formats
- be suitable for integration with persistence libraries
What Montrose doesn't do:
Concepts
Montrose allows you to easily create "recurrence objects" through chaining:
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'.
r1 = Montrose.every(:week)
r2 = r1.on([:tuesday, :thursday])
r3 = r2.at("12 pm")
r4 = r3.total(4)
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...>
See the docs for Montrose::Chainable for more info on recurrence creation methods.
A Montrose recurrence responds to #events, which returns an Enumerator that can generate timestamps:
r = Montrose.hourly
=> #<Montrose::Recurrence...>
r.events
=> #<Enumerator:...>
r.events.take(10)
=> [2016-02-03 18:26:08 -0500,
2016-02-03 19:26:08 -0500,
2016-02-03 20:26:08 -0500,
2016-02-03 21:26:08 -0500,
2016-02-03 22:26:08 -0500,
2016-02-03 23:26:08 -0500,
2016-02-04 00:26:08 -0500,
2016-02-04 01:26:08 -0500,
2016-02-04 02:26:08 -0500,
2016-02-04 03:26:08 -0500]
Montrose recurrences are themselves enumerable:
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]
Conceptually, recurrences can represent an infinite sequence. 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.
r = Montrose.daily
r.map(&:to_date)
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]
It's straightforward to convert a recurrence to a hash and back.
opts = Montrose::Recurrence.new(every: 10.minutes).to_h
=> {:every=>:minute, :interval=>10}
Montrose::Recurrence.new(opts).take(3)
=> [2016-02-03 19:06:07 -0500,
2016-02-03 19:16:07 -0500,
2016-02-03 19:26:07 -0500]
A recurrence object must minimally specify a frequency, e.g. :minute, :hour, :day, :week, :month, or, :year, to be viable. Otherwise, you'll see an informative error message when attempting to enumerate the recurrence.
r = Montrose.at("12pm")
=> #<Montrose::Recurrence...>
r.each
Montrose::ConfigurationError: Please specify the :every option
Usage
require "montrose"
Montrose.r
Montrose.recurrence
Montrose::Recurrence.new
Montrose.daily(total: 10)
starts = Date.new(2015, 1, 1)
ends = Date.new(2015, 12, 23)
Montrose.daily(starts: starts, until: ends)
Montrose.daily(interval: 2)
Montrose.every(10.days, total: 5)
starts = Time.current.beginning_of_year
ends = Time.current.end_of_year + 2.years
Montrose.daily(month: :january, between: starts...ends)
Montrose.weekly(total: 10)
ends_on = Date.new(2015, 12, 23)
starts_on = ends_on - 15.weeks
Montrose.every(:week, until: ends_on, starts: starts_on)
Montrose.every(2.weeks)
Montrose.weekly(on: [:tuesday, :thursday],
between: Date.new(2015, 9, 1)..Date.new(2015, 10, 5))
Montrose.every(2.weeks,
on: [:monday, :wednesday, :friday],
starts: Date.new(2015, 9, 1))
Montrose.weekly(on: [:tuesday, :thursday], total: 8, interval: 2)
Montrose.monthly(day: { friday: [1] }, total: 10)
Montrose.every(:month, day: { friday: [1] }, until: Date.new(2016, 12, 23))
Montrose.every(:month, day: { sunday: [1, -1] }, interval: 2, total: 10)
Montrose.every(:month, day: { monday: [-2] }, total: 6)
Montrose.every(:month, mday: [-3])
Montrose.every(:month, mday: [2, 15], total: 10)
Montrose.monthly(mday: [1, -1], total: 10)
Montrose.every(18.months, total: 10, mday: 10..15)
Montrose.every(2.months, on: :tuesday)
Montrose.yearly(month: [:june, :july], total: 10)
Montrose.every(2.years, month: [:january, :february, :march], total: 10)
Montrose.yearly(yday: [1, 100, 200], total: 10)
Montrose.yearly(day: { monday: [20] })
Montrose.yearly(week: [20], on: :monday)
Montrose.monthly(month: :march, on: :thursday, at: "12 pm")
Montrose.monthly(month: 6..8, on: :thursday)
Montrose.monthly(on: { friday: 13 })
Montrose.monthly(on: { saturday: 7..13 })
Montrose.every(4.years, month: :november, on: { tuesday: 2..8 })
date = Date.new(2016, 9, 1)
Montrose.hourly(between: date..(date+1), hour: 9..17, interval: 3)
Montrose.every(90.minutes, total: 4)
Montrose.every(20.minutes, hour: 9..16)
r = Montrose.every(20.minutes)
r.during("9am-4:40pm")
r.during(time.change(hour: 9)..time.change(hour: 4: min: 40))
r.during([9, 0, 0], [16, 40, 0])
Montrose.every(20.minutes).during("9am-12pm", "1pm-5pm")
Montrose.minutely
Montrose.r(every: :minute)
Montrose.every(10.minutes)
Montrose.r(every: 10.minutes)
Montrose.r(every: :minute, interval: 10)
Montrose.minutely(until: "9:00 PM")
Montrose.r(every: :minute, until: "9:00 PM")
Montrose.daily
Montrose.every(:day)
Montrose.r(every: :day)
Montrose.every(9.days)
Montrose.r(every: 9.days)
Montrose.r(every: :day, interval: 9)
Montrose.daily(at: "9:00 AM")
Montrose.every(:day, at: "9:00 AM")
Montrose.r(every: :day, at: "9:00 AM")
Montrose.daily(total: 7)
Montrose.every(:day, total: 7)
Montrose.r(every: :day, total: 7)
Montrose.weekly
Montrose.every(:week)
Montrose.r(every: :week)
Montrose.every(:week, on: :monday)
Montrose.every(:week, on: [:monday, :wednesday, :friday])
Montrose.every(2.weeks, on: :friday)
Montrose.every(:week, on: :friday, at: "3:41 PM")
Montrose.weekly(on: :thursday)
Montrose.monthly(mday: 1)
Montrose.every(:month, mday: 1)
Montrose.r(every: :month, mday: 1)
Montrose.monthly(mday: [2, 15])
Montrose.monthly(mday: -3)
Montrose.monthly(mday: 10..15)
Montrose.monthly(day: :friday, interval: 2)
Montrose.every(:month, day: :friday, interval: 2)
Montrose.r(every: :month, day: :friday, interval: 2)
Montrose.monthly(day: { friday: [1] })
Montrose.monthly(day: { sunday: [1, -1] })
Montrose.monthly(mday: 7..13, day: :saturday)
Montrose.yearly
Montrose.every(:year)
Montrose.r(every: :year)
Montrose.yearly(month: [:june, :july])
Montrose.yearly(month: 6..8, day: :thursday)
Montrose.yearly(yday: [1, 100])
Montrose.yearly(on: { january: 31 })
Montrose.r(every: :year, on: { 10 => 31 }, interval: 3)
Montrose.weekly.starting(3.weeks.from_now).on(:friday)
Montrose.every(:day).at("4:05pm")
Montrose.yearly.between(Time.current..10.years.from_now)
r = Montrose.every(:month, mday: 31, until: "January 1, 2017")
r.each { |time| puts time.to_s }
r.take(10).to_a
r.merge(starts: "2017-01-01").each { |time| puts time.to_s }
r.events
r.events.take(10).each { |date| puts date.to_s }
r.events.lazy.select { |time| time > 1.month.from_now }.take(3).each { |date| puts date.to_s }
Montrose relies on ActiveSupport for DateTime, Date, and Time calculations. As such, configuring ActiveSupport settings should work for Montrose recurrences.
For example, your application can configure the Date "beginning of the week" (docs):
Date.beginning_of_the_week = :sunday
Date.beginning_of_the_week = :monday
Similarly in Rails (docs):
config.beginning_of_week = :sunday
config.beginning_of_week = :monday
Changing these settings may affect the behavior of Montrose weekly recurrences.
Combining recurrences
It may be necessary to combine several recurrence rules into a single
enumeration of events. For this purpose, there is Montrose::Schedule. To create a schedule of multiple recurrences:
recurrence_1 = Montrose.monthly(day: { friday: [1] })
recurrence_2 = Montrose.weekly(on: :tuesday)
schedule = Montrose::Schedule.build do |s|
s << recurrence_1
s << recurrence_2
end
s << Montrose.yearly
The Schedule#<< method also accepts valid recurrence options as hashes:
schedule = Montrose::Schedule.build do |s|
s << { day: { friday: [1] } }
s << { on: :tuesday }
end
A schedule acts like a collection of recurrence rules that also behaves as a single
stream of events:
schedule.events
schedule.each do |event|
puts event
end
Ruby on Rails
Instances of Montrose::Recurrence support the ActiveRecord serialization API so recurrence objects can be marshalled to and from a single database column:
class RecurringEvent < ApplicationRecord
serialize :recurrence, Montrose::Recurrence
end
Montrose::Schedule can also be serialized:
class RecurringEvent < ApplicationRecord
serialize :recurrence, Montrose::Schedule
end
Inspiration
Montrose is named after the beautifully diverse and artistic neighborhood in Houston, Texas.
Related Projects
Check out following related projects, all of which have provided inspiration for Montrose.
Development
After checking out the repo, run bin/setup to install dependencies. bin/setup will install gems for each gemfile in gemfiles/ against the current Ruby version.
To run tests against all gemfiles for current Ruby:
bin/spec
To update installed gems for gemfiles:
bin/update
To fix lint errors:
bin/standardrb --fix
When adding a new gemfile to gemfiles/, run bin/setup and commit the generated lock file.
You can also run bin/console for an interactive prompt that will allow you to experiment.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rossta/montrose. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.