Available Now JavaScript for Rails Developers

Recurring Calendar Events in Rails

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

✨ Interested in adding a natural language parser instead? Check out this article.

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.

Published at . Have suggestions or improvements on this content? Do reach out.

More articles like this on modern Rails & frontend? Get them first in your inbox.
JavaScript for Rails Developers
Out now

UI components Library for Ruby on Rails apps

$ 99 one-time
payment

View components