This article was originally published on Rails Designer
Last week I released v1.14 of Rails Designer's UI Components. With that release came a fully-customizable Calendar Component, built with ViewComponent and designed with Tailwind CSS.
Since that release I got two times the question via email about recurring events. Does that work? And indeed it does. The Calendar Component simply accepts an events
array/collection. And while this kind of functionality is out-of-scope of a UI component (and the support for it), I am currently working on something that just happens to need this kind of feature. It is not at all too difficult to start (the tricky bits start when hundreds of thousands of events are created 😬). So what else can I do then to share how I would approach this in an article?
The repo for this article can be found here (it does not include the Calendar Component!). Be sure to run bin/rails db:seed
!
The foundation of the recurring rules is done with ice_cube (there are various other gems out there, but this is the one I've used before and know well). It works by storing a JSON-serialized rule set in the Event model (in this example: recurring_rule
and recurring_until
for starters), which defines patterns like “every Monday” or “first day of month”. The gem then provides methods to expand these rules into actual occurrence dates and handles complex recurrence patterns including exceptions, specific weekdays, monthly/yearly rules, and rule combinations.
Let's add it: bundle add ice_cube
.
Next create the Event model: rails g model Event title description:text start:datetime end:datetime recurring_rule:string recurring_until:datetime
.
Simple enough. What I like is to have is an API like this: @events = Event.all.include_recurring
that has sane defaults or when I want to override the default timeframe: @events = Event.all.include_recurring(within: 1.month.from..2.months.from_now)
. Looks pretty good, right?
It's done like this:
module Event::Recurrence
extend ActiveSupport::Concern
included do
serialize :recurring_rule, coder: JSON
end
class_methods do
def include_recurring(within: Time.current..6.months.from_now)
events = all.to_a
recurring_events = events.select(&:recurring_rule).flat_map do |event|
event.schedule.occurrences_between(within.begin, within.end).map do |date|
next if date == event.starts_at
Event::Recurring.new(
event,
starts_at: date,
ends_at: date + (event.ends_at - event.starts_at)
)
end
end.compact
(events + recurring_events).sort_by(&:starts_at)
end
end
def schedule
@schedule ||= IceCube::Schedule.new(starts_at) do |schedule|
schedule.add_recurrence_rule(IceCube::Rule.from_hash(JSON.parse(recurring_rule))) if recurring_rule
end
end
class Event::Recurring
include ActiveModel::Model
delegate :title, :description, :recurring_rule, :schedule, :to_param, to: :@event
attr_reader :starts_at, :ends_at
def initialize(event, starts_at:, ends_at:)
@event = event
@starts_at = starts_at
@ends_at = ends_at
end
def persisted? = false
end
end
Wow—intense! It's not too difficult really! The most interesting things happen in include_recurring
. It looks at all the events and generates future occurrences for the ones that repeat. These occurrences are lightweight Event::Recurring
objects that behave just like regular events but only exist in memory (they mirror the original event but with adjusted dates). The concern then combines the regular events with the generated occurrences and returns them all sorted by starts_at
date. This gives you a complete list of all events, both one-off and recurring, without storing each occurrence in the database. Pretty awesome!
Don't forget to include in the event model:
class Event < ApplicationRecord
include Recurrence
end
And with that you have the basics for recurring events! Sweet!
Creating New Events
The repo with this article has the basics to list and create new (recurring) events. Most of it is basic Rails stuff, but I wanted to highlight the concern: app/models/event/recurrence/builder.rb:
module Event::Recurrence::Builder
extend ActiveSupport::Concern
included do
attr_accessor :recurring_type, :recurring_until
before_save :set_recurring_rule
end
private
def set_recurring_rule
return if recurring_type.blank?
rule = case recurring_type
when "daily" then IceCube::Rule.daily
when "weekly" then IceCube::Rule.weekly.day(starts_at.wday)
when "biweekly" then IceCube::Rule.weekly(2).day(starts_at.wday)
when "monthly" then IceCube::Rule.monthly.day_of_month(starts_at.day)
end
rule = rule.until(recurring_until) if recurring_until.present?
self.recurring_rule = rule.to_hash.to_json
end
end
This concern handles the form-to-database conversion for recurring events. It adds virtual attributes for the form (recurring_type
and recurring_until
) and converts these into IceCube
rules before saving. It uses the event's starts_at
to determine which day to repeat on, so a weekly event starting on Tuesday will always repeat on Tuesdays.
Don't forget to include this concern in the Event model: include Recurrence::Builder
.
Of course, as often, there are plenty of things you could add: exceptions (canceling/modifying single occurrences), multiple days per week (“Monday and Wednesday”) and editing future occurrences only. But this is a good foundation to start with.
Top comments (0)