Recurring Events in Elixir (Phoenix)

Jake Curreri
SmallWorld
Published in
9 min readNov 18, 2022
Event Recursion in Google Calendar

There aren’t very many good resources available for tackling how to implement recurrence. As Elixir developers, recursion should be our matter of expertise. So, here is a guide on implementing event recurrence in Elixir.

Preface

Brian Moeskau wrote an overview of recurrence that is an excellent resource to configure a design pattern for recurrence. Much of this implementation is based on that overview.

#1 rule of recurrence:

Persist a recurrence pattern, not individual recurrence instances. Event instances should be calculated at runtime.

The primary reason for these design choices is optimal querying against the data.

I. Setting up our application

Start by generating our project. Make sure you have the latest version of Phoenix.

mix archive.install hex phx_new

Then, generate a fresh, API-only Phoenix app.

mix phx.new recurring_events --no-html --no-html --no-assets
cd recurring_events

Before we structure our database, let’s think a little bit about our Event module. The general module is basic. We know we’ll need:

  • :id (UniqueId)
  • :title (String)
  • :start_date_utc (DateTime)
  • :end_date_utc (DateTime)
  • :duration (Integer)*
  • :is_all_day (Boolean)

*:duration will make sense later.

Let’s get that setup with some JSON resources along with our database.

mix ecto.create
mix ecto.migrate

Then, to generate the controller, views, and context for the JSON resource:

mix phx.gen.json Social Event events title:string start_date_utc:utc_datetime_usec end_date_utc:utc_datetime_usec duration:integer is_all_day:booleanmix ecto.migrate

And we now have our basic Event module and a Social context.

*Note on context naming: I chose the namespacing of Social for the context since I’m assuming you’re building an application with some sort of social component. If this is purely a calendar application, rename this context to Calendar.

Add the routes to your router.ex

router.ex
...
scope "/api", RecurringEventsWeb do
pipe_through :api
resources "/events", EventController, except: [:new, :edit]
end
...

Test your routing to make sure everything is good thus far.

iex -S mix phx.server

Then, in a browser open http://localhost:4000/api/events You should see an empty data node. Let’s change that.

Add Timex to your dependencies.

mix.exs
...
{:timex, "~> 3.7.9"},
...

Then run mix deps.get

In your seeds.exs add the following snippet:

seeds.exs

And run mix run priv/repo/seeds.exs

Voila. You should have 50 populated events.

II. Designing the Event Recurrence schema

Let’s get into the meat of the guide: event recurrence. I’ll start by sharing the code, then explain my work later.

Generate a migration file to modify the events module:

mix ecto.gen.migration add_recurrence_to_events 

Open the newly generated migration file and add the following:

def change do
alter table(:events) do
add :is_recurring, :boolean, default: false, null: false
add :recurrence_pattern, :string
add :parent_id, references(:events, on_delete: :delete_all)
end
create index(:events, [:parent_id])
end

Add these new columns to Event.ex

Event.ex
...
schema "events" do
...
field :is_recurring, :boolean, default: false
field :recurrence_pattern, :string
belongs_to :parent, __MODULE__
...
end
...
|> cast[..., :parent_id, :is_recurring, :recurrence_pattern]
...

and to event_view.ex

event_view.ex

The render_one_relationship is a custom function. I like to keep this in my View modules for managing complex data preloading. Add these to the end of the file to resolve any compile errors.

At this point, check your server: iex -S mix phx.server

Next, let’s create a new table called event_exceptions

mix phx.gen.json Social EventException event_exceptions event_id:references:events exception_date_utc:utc_datetime_usec

Be sure to add on_delete: :delete_all to the migration file. Otherwise, you’ll run into a headache later when managing event deletion.

No need to add the resources configuration for now. Migrate your new table: mix ecto.migrate

This is not the only possible way to design your schema, but it’s a simple approach that should meet most basic needs. Here are some thoughts that went into it:

  • RecurrencePattern is of course the iCal recurrence string, assuming that the iCal RFC is being followed (e.g., FREQ=DAILY;INTERVAL=10;COUNT=5). If some custom recurrence pattern scheme is in use then this might be different.
  • Duration is strongly recommended in conjunction with recurrence, as explained in the next section. The duration is typically stored as a time value in the minimum resolution supported by your application (e.g. minutes).
  • A separate boolean flag for IsRecurring is not strictly required, but is handy to avoid having to check that the recurrence pattern is not null or empty string throughout your code.
  • For exceptions, there are actually lots of different ways to approach storing them. Another simple option might be avoiding the separate table and storing a delimited string of exception dates as another column in the Eventtable, although the separate table approach leaves open more flexibility for enhancing exception support in the future.

III. Querying single-instance events

Now that we have a nice data structure, let’s begin to build queries for our future requests.

First, we’ll need a search_events function to allow us to search by :title , :start_date_utc , and :end_date_utc .

Here’s that basic implementation leveraging dynamic queries to make our request as flexible as possible.

Querying single-instance events (social.ex)

Add this method to the EventController

event_controller.ex
...
def search(conn, params) do
events = Social.search_events(params) |> RecurringEvents.Repo.all
render(conn, "index.json", events: events)
end
...

And to your router.ex

router.ex
...
get "/events/search", EventController, :search
...

You should now be able to search your events with the following params:

  • :title
  • :from
  • :to

GET http://localhost:4000/api/events/search?from=2022-08-21T00:00:00&to=2022-08-23T00:00:00

Of course, this does not return recurring event instances at runtime. That is where we move next.

IV. Creating instances of recursion at runtime

Let’s add recurrence to one of our events. The recurrence rule will be every day for 10 days: FREQ=DAILY;INTERVAL=1;COUNT=10 according to iCal RFC conventions.

Here is a resource for testing your recurrence patterns: https://jakubroztocil.github.io/rrule/

Open your Elixir console: iex -S mix phx.server

iex(1)> event = RecurringEvents.Social.get_event!(1)
%RecurringEvents.Social.Event{
__meta__: #Ecto.Schema.Metadata<:loaded, "events">,
duration: 60,
end_date_utc: ~U[2022-08-11 20:22:32.658620Z],
id: 1,
inserted_at: ~N[2022-08-10 19:22:32],
is_all_day: false,
is_recurring: false,
parent: #Ecto.Association.NotLoaded<association :parent is not loaded>,
parent_id: nil,
recurrence_pattern: nil,
start_date_utc: ~U[2022-08-11 19:22:32.658620Z],
title: "Event 0",
updated_at: ~N[2022-08-10 19:22:32]
}
iex(2)> event |> RecurringEvents.Social.update_event(%{is_recurring: true, recurrence_pattern: "FREQ=DAILY;INTERVAL=1;COUNT=10"})
{:ok,
%RecurringEvents.Social.Event{
__meta__: #Ecto.Schema.Metadata<:loaded, "events">,
duration: 60,
end_date_utc: ~U[2022-08-11 20:22:32.658620Z],
id: 1,
inserted_at: ~N[2022-08-10 19:22:32],
is_all_day: false,
is_recurring: true,
parent: #Ecto.Association.NotLoaded<association :parent is not loaded>,
parent_id: nil,
recurrence_pattern: "FREQ=DAILY;INTERVAL=1;COUNT=10",
start_date_utc: ~U[2022-08-11 19:22:32.658620Z],
title: "Event 0",
updated_at: ~N[2022-08-10 21:33:14]
}}

Now how do we go about converting the :recurrence_pattern into actual event instances? I made a Hex package for that. Handy! If you have ideas for improving the package, shoot me a message. Future state, I will add is_valid?/1 for data validation before inserting it into the database.

A note on attribution: This Hex package is an extension of Austin Hammer’s RRulex that unfortunately never made it to distribution.

Go ahead and add this to your dependencies and run mix deps.get

def deps do
[
...
{:rrulex, "~> 0.1.0"},
]
end

This package will allow us to convert the recurrence_pattern:String into a usable struct to create the event instances.

Now, you’ll need to create a new module called Manager. Go ahead and do that, copying this code into the new file Manager.ex.

Manager.ex

What is happening here?

  • list_instances/2 takes in events and params to be later used in our Social.search/1 query. This function filters events by is_recurring pipes into create_instances_of_recurrence/1.
  • create_instances_of_recurrence/1 takes in the Event and parses it through RRulex. After that,%RRulex{} and Event are piped into convert_rrules_into_events/2, the final destination.
  • convert_rrules_into_events/2 parses the struct retired from %Rules{} into individual %Event{} instances with a temporary uuidin place of id (useful for frontend API calls). This function handles the most basic use case of recurring events: frequency and count. A readable use case: Repeat this event every week for 5 weeks. The function is easily extendable, but for the sake of complexity, I will only handle this use case.

To test out that everything is working, open your console: iex -S mix phx.server . This will return all instances of recurring events at runtime. Our original goal!

iex(1)> RecurringEvents.Social.list_events |> RecurringEvents.Manager.list_instances
[
%RecurringEvents.Social.Event{
__meta__: #Ecto.Schema.Metadata<:built, "events">,
id: "bea78321-0e1c-46fe-bbba-d7d6a4229aba",
...
]

V. Querying multi-instance (recurring) events

Now that we have an Event that is recurring — and we’re generating instances of that recurrence at runtime — we need to re-configure our Social.search/1 query to return instances of the recurring parent event.

Before we do that, we need to modify the :create and :update methods of the EventController.

Modifying the end_date_utc of the parent event

If you remember from earlier, we decided to use :duration in our Eventschema. Yes, we could have calculated this from the difference between :start_date_utc and :end_date_utc but that quickly runs into a problem:

The problem is that the start and end dates are only for the first occurrence of the pattern, so what happens when the view requests “all events between February 1 and February 29, 2016”? January 1 is not in that range, so in order to know at query time if that event should be returned, you would have to parse the recurrence pattern, during the query, for every recurring event in the database.

The other option would be to always return every single recurring event in the database with a start date prior to the query date range, then process each one in code. Ouch.

So, let’s update the :end_date_utc for the parent Event of a recursion pattern.

Go to Social.ex and add this function below update_event/2

With this function in place, we can use check_and_update_for_recurrence/1 in our EventController under :create and :update

:create and :update in event_controller.ex

And if you haven’t already, add an :update_changeset to Event.ex that does not require validation. This isn’t necessary, but I like to leave it up to the frontend on whether to send a full data snippet for an update.

Event.ex
...
@doc false
def update_changeset(event, attrs) do
event
|> cast(attrs, [:title, :start_date_utc, :end_date_utc, :duration, :is_all_day, :parent_id, :is_recurring, :recurrence_pattern])
end

Now when we create a recurring event, the parent event could fall within an applicable query range. Otherwise, if we were querying over a time range that did not include the original event instance, recurring instances would not be returned in the data set.

Modifying the search query for events

While you can’t avoid parsing recurrence rules at some point in the process, the goal is to filter out as many events as possible during the query, and without adding excessive query overhead. The basic rules are:

  • Start date and end date should always contain valid date/time values (not null)
  • The start/end combination should always represent the entire possible range of dates matching the recurrence range

This means that if an event has recurrence, the stored end date will always be the end date for the recurrence pattern, even when no end date is explicitly specified, which is why we need a separate duration field that tells us how long each event instance should be. Note that you could alternately add a recurrence-specific end date instead of duration, or even store additional columns calculated from the recurrence pattern if needed. Again, there are many possible ways to solve it, but the takeaway is:

You must be able to distinguish between the recurrence pattern end date and the end date of each event instance to enable practical querying

…search query for events example coming soon 😀

VI. Further exploration

In a future article — or a future update to this article — I might go into depth on the other components of event recursion. But, this gives the optimal starting point (and hopefully helps you avoid common pitfalls).

A non-exhaustive list for further exploration:

  • Handling EventExceptions: Using the table we created earlier, we could modify the create_instances_of_recurrence/2to exclude the :exception_date_utc from the associated EventException.
  • Create single-instance events from a recurrence pattern: In the case where an event is needed in the database — for example, if the parent event were deleted, but not the events following; or, if you added the ability for users to reserve an event — you would need to create that event. Though, frontend logic could handle this just as well (i.e. the first user to register for an event triggers the create method in the database).
  • Handling event deletion: Similar to the preceding point, this is a common component of Calendar UIs: to be able to cancel this event, this and all following events, or all events in the series. Each of those 3 options entails a separate type of functionality.
Handling event deletion

I hope you find this guide helpful. Leave a comment below with any suggestions to improve this resource. Happy coding!

The complete source code for this guide is available here: https://github.com/jakecurreri/recurring_events_in_elixir

--

--

Jake Curreri
SmallWorld

Founder of SmallWorld. Creator of Baseline. Street guest on the Tonight Show (once). Unsuccessfully auditioned for the Disney Channel. Elixir, RN, RoR.