Recurring Events in Elixir (Phoenix)
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:
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
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
Event
table, 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.
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
.
What is happening here?
list_instances/2
takes inevents
andparams
to be later used in ourSocial.search/1
query. This function filters events byis_recurring
pipes intocreate_instances_of_recurrence/1
.create_instances_of_recurrence/1
takes in theEvent
and parses it throughRRulex
. After that,%RRulex{}
andEvent
are piped intoconvert_rrules_into_events/2
, the final destination.convert_rrules_into_events/2
parses the struct retired from%Rules{}
into individual%Event{}
instances with a temporaryuuid
in place ofid
(useful for frontend API calls). This function handles the most basic use case of recurring events:frequency
andcount
. 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 Event
schema. 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
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 thecreate_instances_of_recurrence/2
to exclude the:exception_date_utc
from the associatedEventException
. - 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.
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