Recurring Events in Rails

21 Aug 2016

Modeling recurring events in a calendar is an interesting problem to solve.
There are many different scenarios
in which you might need to model recurring events,
but in this article I will walk through
a simple example of weekly recurring events in a Rails app.

Although this post covers most of the code needed for the example,
I will skip over some of the details (such as views),
and you will have to fill in the blanks in such places.

Much of the code here is taken from a real project I’m working on,
and I’ve tried to simplify the code as much as possible.
However, some of the tradeoffs that I needed in my project
aren’t really needed in this example.
I have pointed out some cases where this is true.

Scheduling weekly events

Let’s take an example where we will need to model weekly recurring events:

Our users want to be able to save weekly events,
with a day of the week and a time.
The system will perform an action every week on that day and time.
For example, a user could set up a reminder
(“remind me to send status report at 5pm every Friday”),
and the system should send an email at that time every week.

Modeling recurring events

Before we start writing the code for scheduling events,
let’s briefly think about how we will implement this.

We will start with a RecurringEvent model,
which will contain the day and time at which the event must be performed.
We could also call this model something like Reminder,
but since we could have other things in future that could be recurring,
I prefer having a RecurringEvent model and associate that with other models.
To keep things simple in this post,
we will just have a reminder string field in the RecurringEvent model.

The user will create a recurring event using a form
that contains the following fields:

reminder (eg. “Send weekly status report”)

time - we will just save a string containing the time, (eg. “5:00 pm”)

day - day of the week, as an integer (0 for Sunday, 1 for Monday, etc)

We will use an Event model to save the actual time at which
a RecurringEvent occurs.
We will save the next instance of the recurring event in the Event model.

We will also use ActiveJob to schedule the event at the correct time
using the RunEventJob class.
This ActiveJob class will execute the action that should happen at the time
(sending the reminder email, in this example),
and also create the next instance of the event.

Persisting a RecurringEvent

Now that we have everything in place, let’s look at RecurringEvents::Create.
Here we just persist a RecurringEvent to the database,
and call Events::Schedule service class to create a new instance of Event
with the run_at field set correctly.

The RunEventJob should contain the code for whatever should happen at the time,
and at the end calls Events::Schedule#call
to queue up the next occurence of the event.

classRunEventJob<ApplicationJobqueue_as:defaultdefperform(event)# Perform the relevant work here. Example:# ReminderMailer.notify(event).deliver_later!# Schedule the next occurence of the eventEvents::Schedule.new(event.recurring_event).callendend

Timezone considerations

Remember the timezone problem I mentioned in NextRecurringEventDate#calculate?
When a user wants to send a reminder at “Friday 5PM”,
they usually mean 5pm on Friday in their timezone.
We should make sure that the event time that we calculate
uses the correct timezone offset.

In this article I have not mentioned a User model.
Let’s assume that we do have such a model,
and we have associated RecurringEvent as belonging to a user.

We will also need to be aware of the user timezone.
This is another thing that I will not be addressing in this article,
but let’s assume that we can access the user’s timezone as
recurring_event.user.timezone.
(My previous article on
setting user timezones during user signup
might be useful at this point.)

With that, we now have a working weekly recurring events system.
There are many more things to consider, though,
and I will list some gotchas below.

Wrapping up

A couple of gotchas you might need to consider
if you’re building a similar feature:

When you allow users to delete a RecurringEvent,
you might see the ActiveJob::DeserializationError in the RunEventJob.
This exception could also be caused by other factors,
such as the database being down.
We need to always retry unless the exception is ActiveRecord::RecordNotFound.
The following code achieves that:

When users edit a RecurringEvent,
make sure that you update the next Event occurence.

As I mentioned at the start of the article,
there are places where I’ve retained code
that isn’t needed in a simple example like this one.
For instance, I needed a model for each event occurence
because I need to add additional information there,
but here you could just use a field in the RecurringEvent model.

There are other scenarios in which you might need recurring events,
such as “second Saturday of every month”,
and this article doesn’t cover such advanced scenarios.

Hi, I’m Nithin Bekal.
I work at Shopify in Ottawa, Canada.
Previously, co-founder of
CrowdStudio.in and
WowMakers.
Ruby is my preferred programming language,
and the topic of most of my articles here,
but I'm also a big fan of Elixir.
Tweet to me at @nithinbekal.