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 # => trueQuick 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 nilMonthly 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 # => trueCOUNT 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
endThe Recurable Concern
Recurable is an ActiveSupport::Concern included by ActiveRecord models with an rrule string column:
class Plan < ApplicationRecord
include Recurable
endIt provides:
-
Serialization —
serialize :rrule, RecurrenceSerializertransparently converts between DB strings andRecurrenceobjects -
Delegation — all
Recurrenceattributes and frequency predicates are delegated to therruleobject - Validation — recurrence attributes are validated with appropriate constraints (frequency inclusion, interval positivity, array value ranges, BYDAY pattern matching, COUNT/UNTIL mutual exclusivity)
-
Time projection — includes
RruleUtilsforrecurrence_times,last_recurrence_time_before,next_recurrence_time_after, andhumanize_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)