Montrose

Build Status Code Climate Coverage Status

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:

“by 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:

“by

Every Monday at 10:30am

Montrose.weekly.on(:monday).at(“10:30 am”)

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’.

“by

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:

“by

Montrose.r(every: :week, on: :monday, at: “10:30 am”)

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:

“by

r = Montrose.hourly

r.events

r.events.take(10)

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:

“by

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

“by

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

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.

“by

opts = Montrose::Recurrence.new(every: 10.minutes).to_h

Montrose::Recurrence.new(opts).take(3)

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.

“by

r = Montrose.at(“12pm”)

r.each Montrose::ConfigurationError: Please specify the :every option

Usage

“by require “montrose”

a new recurrence

Montrose.r Montrose.recurrence Montrose::Recurrence.new

daily for 10 occurrences

Montrose.daily(total: 10)

daily until December 23, 2015

starts = Date.new(2015, 1, 1) ends = Date.new(2015, 12, 23) Montrose.daily(starts: starts, until: ends)

every other day forever

Montrose.daily(interval: 2)

every 10 days 5 occurrences

Montrose.every(10.days, total: 5)

everyday in January for 3 years

starts = Time.current.beginning_of_year ends = Time.current.end_of_year + 2.years Montrose.daily(month: :january, between: starts…ends)

weekly for 10 occurrences

Montrose.weekly(total: 10)

weekly until December 23, 2015

ends_on = Date.new(2015, 12, 23) starts_on = ends_on - 15.weeks Montrose.every(:week, until: ends_on, starts: starts_on)

every other week forever

Montrose.every(2.weeks)

weekly on Tuesday and Thursday for five weeks

from September 1, 2015 until October 5, 2015

Montrose.weekly(on: [:tuesday, :thursday], between: Date.new(2015, 9, 1)..Date.new(2015, 10, 5))

every other week on Monday, Wednesday and Friday until December 23 2015,

but starting on Tuesday, September 1, 2015

Montrose.every(2.weeks, on: [:monday, :wednesday, :friday], starts: Date.new(2015, 9, 1))

every other week on Tuesday and Thursday, for 8 occurrences

Montrose.weekly(on: [:tuesday, :thursday], total: 8, interval: 2)

monthly on the first Friday for ten occurrences

Montrose.monthly(day: { friday: [1] }, total: 10)

monthly on the first Friday until December 23, 2015

Montrose.every(:month, day: { friday: [1] }, until: Date.new(2016, 12, 23))

every other month on the first and last Sunday of the month for 10 occurrences

Montrose.every(:month, day: { sunday: [1, -1] }, interval: 2, total: 10)

monthly on the second-to-last Monday of the month for 6 months

Montrose.every(:month, day: { monday: [-2] }, total: 6)

monthly on the third-to-the-last day of the month, forever

Montrose.every(:month, mday: [-3])

monthly on the 2nd and 15th of the month for 10 occurrences

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)

every 18 months on the 10th thru 15th of the month for 10 occurrences

Montrose.every(18.months, total: 10, mday: 10..15)

every Tuesday, every other month

Montrose.every(2.months, on: :tuesday)

yearly in June and July for 10 occurrences

Montrose.yearly(month: [:june, :july], total: 10)

every other year on January, February, and March for 10 occurrences

Montrose.every(2.years, month: [:january, :february, :march], total: 10)

every third year on the 1st, 100th and 200th day for 10 occurrences

Montrose.yearly(yday: [1, 100, 200], total: 10)

every 20th Monday of the year, forever

Montrose.yearly(day: { monday: [20] })

Monday of week number 20 forever

Montrose.yearly(week: [20], on: :monday)

every Thursday in March, forever

Montrose.monthly(month: :march, on: :thursday, at: “12 pm”)

every Thursday, but only during June, July, and August, forever“ do

Montrose.monthly(month: 6..8, on: :thursday)

every Friday 13th, forever

Montrose.monthly(on: { friday: 13 })

first Saturday that follows the first Sunday of the month, forever

Montrose.monthly(on: { saturday: 7..13 })

every four years, the first Tuesday after a Monday in November, forever (U.S. Presidential Election day)

Montrose.every(4.years, month: :november, on: { tuesday: 2..8 })

every 3 hours from 9:00 AM to 5:00 PM on a specific day

date = Date.new(2016, 9, 1) Montrose.hourly(between: date..(date+1), hour: 9..17, interval: 3)

every hour and a half for four occurrences

Montrose.every(90.minutes, total: 4)

every 20 minutes from 9:00 AM to 4:40 PM every day

Montrose.every(20.minutes, hour: 9..16)

every 20 minutes from 9:00 AM to 4:40 PM every day with time-of-day precision

r = Montrose.every(20.minutes) r.during(“9am-4:40pm”) # as semantic time-of-day range OR r.during(time.change(hour: 9)..time.change(hour: 4: min: 40)) # as ruby time range OR r.during([9, 0, 0], [16, 40, 0]) # as hour, min, sec tuple pairs for start, end

every 20 minutes during multiple time-of-day ranges

Montrose.every(20.minutes).during(“9am-12pm”, “1pm-5pm”)

Minutely

Montrose.minutely Montrose.r(every: :minute)

Montrose.every(10.minutes) Montrose.r(every: 10.minutes) Montrose.r(every: :minute, interval: 10) # every 10 minutes

Montrose.minutely(until: “9:00 PM”) Montrose.r(every: :minute, until: “9:00 PM”)

Daily

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)

Weekly

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)

Monthly by month day

Montrose.monthly(mday: 1) # first of the month Montrose.every(:month, mday: 1) Montrose.r(every: :month, mday: 1)

Montrose.monthly(mday: [2, 15]) # 2nd and 15th of the month Montrose.monthly(mday: -3) # third-to-last day of the month Montrose.monthly(mday: 10..15) # 10th through the 15th day of the month

Monthly by week day

Montrose.monthly(day: :friday, interval: 2) # every Friday every other month Montrose.every(:month, day: :friday, interval: 2) Montrose.r(every: :month, day: :friday, interval: 2)

Montrose.monthly(day: { friday: [1] }) # 1st Friday of the month Montrose.monthly(day: { sunday: [1, -1] }) # first and last Sunday of the month

Montrose.monthly(mday: 7..13, day: :saturday) # first Saturday that follow the first Sunday of the month

Yearly

Montrose.yearly Montrose.every(:year) Montrose.r(every: :year)

Montrose.yearly(month: [:june, :july]) # yearly in June and July Montrose.yearly(month: 6..8, day: :thursday) # yearly in June, July, August on Thursday Montrose.yearly(yday: [1, 100]) # yearly on the 1st and 100th day of year

Montrose.yearly(on: { january: 31 }) Montrose.r(every: :year, on: { 10 => 31 }, interval: 3)

Chaining

Montrose.weekly.starting(3.weeks.from_now).on(:friday) Montrose.every(:day).at(“4:05pm”) Montrose.yearly.between(Time.current..10.years.from_now)

Enumerating events

r = Montrose.every(:month, mday: 31, until: “January 1, 2017”) r.each { |time| puts time.to_s } r.take(10).to_a

Merging rules

r.merge(starts: “2017-01-01”).each { |time| puts time.to_s }

Using #events Enumerator

r.events # => #Enumerator: ... 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 }

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:

“by 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

add after building

s « Montrose.yearly

The Schedule#<< method also accepts valid recurrence options as hashes:

“by 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:

“by schedule.events # => #Enumerator: ... 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:

“by class RecurringEvent < ApplicationRecord serialize :recurrence, Montrose::Recurrence

end

Montrose::Schedule can also be serialized:

“by class RecurringEvent < ApplicationRecord serialize :recurrence, Montrose::Schedule

end

Inspiration

Montrose is named after the beautifully diverse and artistic neighborhood in Houston, Texas.

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. Then, run bin/rake to run the tests.

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.