Project

recurable

0.0
No release in over 3 years
Provides Recurrence, RruleUtils, and RecurrenceSerializer as a standalone library for working with iCal RRULE recurrence patterns (full RFC 5545 support). Optionally integrates with Rails via the Recurable concern for transparent ActiveRecord serialization. Handles DST-safe projection for all frequencies from minutely through yearly.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7, < 9
>= 7, < 9
~> 0.5
 Project Readme

Recurable

iCal RRULE recurrence library for Ruby with optional Rails/ActiveRecord integration. Full RFC 5545 RRULE support.

Quick Start: Standalone

No Rails required. Just ActiveModel and ActiveSupport.

require 'recurable/recurrence'

# Build a recurrence from attributes
recurrence = Recurrence.new(frequency: 'DAILY', interval: 1)
recurrence.to_rrule   # => "FREQ=DAILY;INTERVAL=1"

# Parse an existing RRULE string
recurrence = Recurrence.from_rrule('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR')
recurrence.frequency  # => "WEEKLY"
recurrence.interval   # => 2
recurrence.by_day     # => ["MO", "WE", "FR"]

# Frequency predicates
recurrence.weekly?    # => true
recurrence.daily?     # => false

# Frequency comparison (YEARLY < MONTHLY < ... < MINUTELY)
yearly  = Recurrence.new(frequency: 'YEARLY')
monthly = Recurrence.new(frequency: 'MONTHLY')
yearly < monthly      # => true

Quick Start: With Rails

Add the gem, then prepend the Recurable concern on any model with an rrule string column:

class Plan < ApplicationRecord
  include Recurable
end

plan = Plan.new
plan.frequency = 'MONTHLY'
plan.interval = 3
plan.by_month_day = [15]
plan.rrule                    # => #<Recurrence> with to_rrule "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15"
plan.valid?                   # validates both the model and the recurrence
plan.monthly?                 # => true
plan.humanize_recurrence      # => "every 3 months on the 15th"

# Time projection
plan.recurrence_times(
  project_from: Time.zone.local(2026, 1, 1),
  project_to:   Time.zone.local(2026, 7, 1)
)

# Boundary queries
plan.last_recurrence_time_before(Time.zone.now, dt_start_at: plan.created_at)
plan.next_recurrence_time_after(Time.zone.now, dt_start_at: plan.created_at)

Installation

Standalone (no Rails):

gem 'recurable'

# Then in your code:
require 'recurable/recurrence'

With Rails:

gem 'recurable'

# Then in your code:
require 'recurable'

Requires ActiveRecord >= 7.1 for serialize with default: keyword support.

Recurrence Attributes

Recurrence is a pure Ruby data class with named attributes mapping to RFC 5545 RRULE components:

Attribute Type RRULE Component Example Description
frequency String FREQ "MONTHLY" Every month
interval Integer INTERVAL 3 Every 3rd frequency period
by_day Array<String> BYDAY ["+2MO"] The second Monday
by_day ["MO", "WE", "FR"] Monday, Wednesday, and Friday
by_month_day Array<Integer> BYMONTHDAY [1, 15] The 1st and 15th of the month
by_set_pos Array<Integer> BYSETPOS [-1] The last occurrence in the set
count Integer COUNT 10 Stop after 10 occurrences
repeat_until Time (UTC) UNTIL Time.utc(2026, 12, 31) Stop after December 31, 2026
hour_of_day Array<Integer> BYHOUR [9, 17] At 9 AM and 5 PM
minute_of_hour Array<Integer> BYMINUTE [0, 30] At :00 and :30 past the hour
second_of_minute Array<Integer> BYSECOND [0, 30] At :00 and :30 past the minute
month_of_year Array<Integer> BYMONTH [1, 6] In January and June
day_of_year Array<Integer> BYYEARDAY [1, -1] First and last day of the year
week_of_year Array<Integer> BYWEEKNO [1, 52] Weeks 1 and 52
week_start String WKST "MO" Weeks start on Monday

All array attributes accept scalars (auto-wrapped) or arrays. nil and [] are normalized to nil.

recurrence = Recurrence.new
recurrence.by_day = 'MO'       # => stored as ["MO"]
recurrence.by_day = %w[MO FR]  # => stored as ["MO", "FR"]
recurrence.by_day = []          # => stored as nil

Monthly Recurrence Options

Monthly recurrences support two modes, determined by which attributes are set:

# By date: "the 15th of every month"
Recurrence.new(frequency: 'MONTHLY', interval: 1, by_month_day: [15])
  .monthly_option         # => "DATE"

# By nth weekday: "the last Friday of every month"
Recurrence.new(frequency: 'MONTHLY', interval: 1, by_day: ['FR'], by_set_pos: [-1])
  .monthly_option         # => "NTH_DAY"

RRULE Generation & Parsing

# Generate: attributes → RRULE string
recurrence = Recurrence.new(frequency: 'MONTHLY', interval: 1, by_day: ['FR'], by_set_pos: [-1])
recurrence.to_rrule  # => "FREQ=MONTHLY;INTERVAL=1;BYDAY=FR;BYSETPOS=-1"

# Parse: RRULE string → Recurrence
parsed = Recurrence.from_rrule('FREQ=MONTHLY;INTERVAL=1;BYDAY=FR;BYSETPOS=-1')
parsed.by_day        # => ["FR"]
parsed.by_set_pos    # => [-1]

# Round-trip
parsed.to_rrule == recurrence.to_rrule  # => true

COUNT and UNTIL

COUNT and UNTIL are mutually exclusive per RFC 5545. The Recurable concern validates this:

# Limit by count
Recurrence.new(frequency: 'DAILY', interval: 1, count: 10)
  .to_rrule  # => "FREQ=DAILY;INTERVAL=1;COUNT=10"

# Limit by end date (stored as UTC)
Recurrence.new(frequency: 'DAILY', interval: 1, repeat_until: Time.utc(2026, 12, 31, 23, 59, 59))
  .to_rrule  # => "FREQ=DAILY;INTERVAL=1;UNTIL=20261231T235959Z"

Time Projection

RruleUtils is an includable module for DST-aware time projection. Any object with a recurrence method returning a Recurrence can include it. The Recurable concern includes it automatically.

# Project occurrences in a date range
model.recurrence_times(
  project_from: Time.zone.local(2026, 1, 1),
  project_to:   Time.zone.local(2026, 2, 1),
  dt_start_at:  model.created_at          # optional; defaults to project_from
)

# Find the last occurrence before a boundary
model.last_recurrence_time_before(Time.zone.now, dt_start_at: model.created_at)

# Find the next occurrence after a boundary
model.next_recurrence_time_after(Time.zone.now, dt_start_at: model.created_at)

# Human-readable description
model.humanize_recurrence  # => "every 3 months on the 15th"

Time projection delegates to the rrule gem with timezone-aware DST handling.

DST Boundary Behavior

Daily and sub-daily recurrences behave differently across DST transitions.

Spring forward — On March 12, 2023 in America/New_York, clocks jump from 2:00 AM EST to 3:00 AM EDT.

A daily recurrence at 1:00 PM is unaffected — the wall-clock time stays consistent across the boundary:

Mar 11  1:00 PM EST
Mar 12  1:00 PM EDT  ← DST transition happened earlier this day, but 1 PM still fires
Mar 13  1:00 PM EDT

An hourly recurrence skips the non-existent 2:00 AM hour, producing 23 unique hours:

12:00 AM EST
 1:00 AM EST
 3:00 AM EDT  ← 2:00 AM doesn't exist, jumps straight to 3:00 AM
 4:00 AM EDT
 ...
11:00 PM EDT

Fall back — On November 5, 2023, clocks fall back from 2:00 AM EDT to 1:00 AM EST. The 1:00 AM hour occurs twice, but the duplicate is removed:

12:00 AM EDT
 1:00 AM EDT
 1:00 AM EST  ← duplicate wall-clock hour, removed by .uniq
 2:00 AM EST
 3:00 AM EST
 ...
11:00 PM EST

This produces 24 unique wall-clock hours despite the repeated 1:00 AM.

Try it yourself:

Time.use_zone('America/New_York') do
  spring_forward = Time.zone.local(2023, 3, 12)

  # Daily at 1 PM: fires every day regardless of DST
  daily = Recurrence.new(frequency: 'DAILY', interval: 1)
  model = Struct.new(:recurrence).new(daily).extend(RruleUtils)
  model.recurrence_times(
    project_from: Time.zone.local(2023, 3, 11, 13),
    project_to:   Time.zone.local(2023, 3, 13, 13),
    dt_start_at:  Time.zone.local(2023, 3, 11, 13)
  ).map { |t| t.strftime('%b %d %l:%M %p %Z') }
  # => ["Mar 11  1:00 PM EST", "Mar 12  1:00 PM EDT", "Mar 13  1:00 PM EDT"]

  # Hourly on spring-forward day: 23 unique hours, 2 AM is skipped
  hourly = Recurrence.new(frequency: 'HOURLY', interval: 1)
  model  = Struct.new(:recurrence).new(hourly).extend(RruleUtils)
  hours  = model.recurrence_times(
    project_from: spring_forward.beginning_of_day,
    project_to:   spring_forward.end_of_day,
    dt_start_at:  spring_forward
  )
  hours.size # => 23

  # Hourly on fall-back day: 24 unique hours, duplicate 1 AM removed
  fall_back = Time.zone.local(2023, 11, 5)
  model = Struct.new(:recurrence).new(hourly).extend(RruleUtils)
  hours = model.recurrence_times(
    project_from: fall_back.beginning_of_day,
    project_to:   fall_back.end_of_day,
    dt_start_at:  Time.zone.local(2023, 11, 1)
  )
  hours.size # => 24
end

The Recurable Concern

Recurable is an ActiveSupport::Concern included by ActiveRecord models with an rrule string column:

class Plan < ApplicationRecord
  include Recurable
end

It provides:

  1. Serializationserialize :rrule, RecurrenceSerializer transparently converts between DB strings and Recurrence objects
  2. Delegation — all Recurrence attributes and frequency predicates are delegated to the rrule object
  3. Validation — recurrence attributes are validated with appropriate constraints (frequency inclusion, interval positivity, array value ranges, BYDAY pattern matching, COUNT/UNTIL mutual exclusivity)
  4. Time projection — includes RruleUtils for recurrence_times, last_recurrence_time_before, next_recurrence_time_after, and humanize_recurrence

Supported Frequencies

Frequency Period Example
YEARLY ~365 days FREQ=YEARLY;INTERVAL=1;BYMONTH=1,6
MONTHLY ~31 days FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15
WEEKLY 7 days FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR
DAILY 1 day FREQ=DAILY;INTERVAL=1
HOURLY 1 hour FREQ=HOURLY;INTERVAL=4;BYMINUTE=0,30
MINUTELY 1 minute FREQ=MINUTELY;INTERVAL=15

Constants

Recurrence exposes named constants for use in validations and logic:

Recurrence::DAILY          # => "DAILY"
Recurrence::MONDAY         # => "MO"
Recurrence::SUNDAY         # => "SU"
Recurrence::MONTHLY_DATE   # => "DATE"
Recurrence::MONTHLY_NTH_DAY # => "NTH_DAY"
Recurrence::FREQUENCIES    # => {"YEARLY"=>365, "MONTHLY"=>31, ...}
Recurrence::DAYS_OF_WEEK   # => ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
Recurrence::NTH_DAY_OF_MONTH # => {first: 1, second: 2, ..., last: -1}

Requirements

  • Ruby >= 3.3
  • ActiveModel >= 7.1
  • ActiveSupport >= 7.1
  • ActiveRecord >= 7.1 (only if using the Recurable concern)

License

MIT