Skip to main content

24 Recurring events with the ice_cube gem

Episode 159 · November 28, 2016

See how to take rules from the ice_cube gem and add recurring events to any calendar

Gems


Transcripts

What's up guys? This episode we're talking about how to add recurring events into the simple calendar UI and how to create them, how to save them in the database and how to display them. Now this feature seems simple as a human being, right? You have an event, you want to tell it to recurr every single Tuesday and so when you display a calendar it should just display one every Tuesday right? Well, it's not really that simple, what we need to do is we need to figure out how to store the rules in the database, how to pull the rules out, how to calculate those rules based on the days that we want to display on the calendar and then how to take those events and put them in the calendar, so there's a lot to it, and that's not to mention all the exceptions that you might want to add to those, so maybe you do a every Monday meeting, but then there's a holiday and you want to delete that meeting for that one day, not the entire recurring thing. You just want to put an exception for the one day, what do you do? Well these two episodes we're going to cover that, we're going to be using the ice_cube gem. This let's you define rules for the recurring events and we'll use that in order to generate events for the calendar. We're also going to use recurring_select. This gem allows us to create a select box where we can say: Set the schedule and it pops up a little modal, and we can choose that we want a weekly event from Monday and Friday every single week, hit OK and that will save it into our database. There's a lot going on here so let's just get started with the code. Let's just create a new rails app rails new recurring_events and we can cd into recurring events and open up our Gemfile and let's go to the bottom and say gem 'simple_calendar' and gem 'recurring_select' This is going to give us that select box with the modal, but we need to add a GitHub recurring select fork so it will be compatible with rails 5. Rundown app has forked it and made it compatible with rails 5, so we'll go ahead and use that version. We can run bundle to install this, and that will give us both the calendar and recurring select depends on the ice_cube gem so we automatically have that installed just by adding recurring_select Let's create our model for this. Generate a scaffold named event. Each event is going to have a name, they'll also have a start time, so this will be for the one-time events so we can add those to specific days and times and then we'll have a recurring attributes in the text column and this will be a serialized hash that we will save into the database and be able to reload and easily load into ice_cube. Now that we've got our model, we can run rake db:migrate to set up our database, and we need to go and modify our form so that we can use that select. rather than a text area we will have:

app/views/events/_form.html.erb

<div class="field">
    <%= f.label :recurring %>
    <%= f.select_recurring :recurring %>
</div>

To add the recurring drop down and the modal for that, but we can also pass a nil and allow blank as true, and this is going to give us the ability for us to clear out a schedule that was already added, so if we want to edit we could actually make it a one-time event instead. We'll add that and we can load this up in our rails server and check it out. Our application is going to have both those one time events, so I'll say: One time event, and we'll create that for right now, and then we'll also have our event for recurring event and this should set the schedule. This doesn't quite work because we need to add our application.js to //= require recurring_select and we also need in our application.css to add *= require recurring_select now if we refresh this, and we try our set schedule we will get the JavaScript and the CSS for it and we can choose our days and recurring events will be saved. This time you'll see that it saves some data into the recurring column, and the other weirdly enough saved the string of null and that's because it's serializing this JSON and it's sending it over and saving it in that column and we need to actually be able to parse this out rather than using the JSON we want to just save this in our own serialized column. So what we'll do is we'll override the setter for the recurring column, and we will parse that out into a hash if it's a valid recurring rule, and if it's not, we'll just save nil to it so we don't have the string of nul, we'll actually have nil in the database. Now we'll keep it consistent for us to query against. Let's open up the event model and let's serialize the recurring column to a hash.

app/models/event.rb

class Event < ApplicationRecord 
    serialize :recurring, Hash 

    def recurring=(value)
        if RecurringSelect.is_valid_rule?(value)
            super(RecurringSelect.dirty_hash_to_rule(value).to_hash)
        else
            super(nil)
        end
    end
end

If we save that and try this out in the browser, we can now create a one time event without the recurring stuff, and you'll see there's an actual empty ruby hash here instead of the string of nul, and if we create a recurring event and we set this to: Weekly every Monday we can create that event and you see symbols here instead of strings, so this is definitely ruby format instead of JSON format. So that's a ruby hash and that means that our conversions have been working and now we can go work on taking those rules and converting them into events so that we can display them in the calendar. The easiest place to start is probably to add that calendar in and get the one-time events added to the calendar so let's do that. Let's go into our index and let's say

app/views/events/index.html.erb

<%= month_calendar events: @events do |day, events| %>
    <div><strong><%= day.to_s(:long) %></strong></div>
    <% events.each do |event| %> 
    <div>
        <%= link_to event.name, event %>
    </div>
    <% end %> 
<% end %> 

This should print out our events. Now this doesn't actually know the difference between our one-time events and our recurring events, so we need to figure out how we want to handle that. The way I'm going to handle this is we're going to create a new array called calendar_events and this will include all the one-time events plus all of the generated events for the recurring ones. We can go into our

app/controllers/events_controller.rb

def index 
    @events = Event.all 
    @calendar_events = @events.flat_map{ |e| e.calendar_events(params.fetch(:start_date, Time.zone.now).to_date ) }
end

We have our fetch, we call that method, and now we need to create that method in our event model. Here is where we add our calendar_events method and it will take that start date and we need to figure out how to populate this. First thing is first, if it's a one time event we want to just return that one event, but if it's multiple events we want to return an array for that recurring one that might be daily or weekly or whatever the case, so if the recurring rules exist, we want to use those in Ice Cube to generate that array for our time frame:

app/models/event.rb

def rule
    IceCube::Rule.from_hash recurring 
end 

def schedule(start)
    schedule = IceCube::Schedule.new(start)
    schedule.add_recurrence_rule(rule)
    schedule
end 

def calendar_events(start)
    if recurring.empty? 
        [self]
    else
        start_date = start.beginning_of_month.beginning_of_week 
        end_date = start.end_of_month.end_of_week 
        schedule(start_date).occurrences(end_date).map do |date|
            Event.new(id: id, name: name, start_time: date)
        end
    end
end

This schedule is going to allow us to add in all those rules that might be necessary for example, we might have some exceptions and so if we put those exceptions in here as well as part of our call but we don't have those yet, we'll talk about those in the future, and for now we can do this. Now that we've got our schedule we can use it to generate that array of events. We grab the beginning of the week for our calendar view so if you're doing a week view or something like that, your calculation here would be a little bit different because you would need to calculate maybe just the beginning of the week not the beginning of the month. You can change that accordingly and we'll have our end date and this is similar. We'll have that and be able to call our schedule and our occurrences will just give us an array of days of times back and we can give it an end date so that it knows how many to calculate, we can go through each of these here and grab the date into a variable and create our new events, We can create our new events, we'll give it the same id as the one in the database, so this is just in memory. We're telling it to create a duplicate id, but that's just because we're going to create this in memory and display this in the calendar and so it will always link back to the same event, so rather than creating these in the database, we're going to create it in memory, but when you generate that link_to it's always going to point to the exact same event. This is our work around in order to get those to display and interact with just as if there were regular old records even though they're not, so we'll assign it the same name and you would want to assign all the other attributes that you might like in here, and we'll assign the start time to this to be that date.

One customization that you might want to make is that if you want to put a specific time on here, you can go ahead and modify this date to take the start time out of the event. So if you wanted the user to create a record that happened at nine am every Monday, you can go ahead and take the original 9am and assign it to your new one in memory, and that will go ahead and allow us to create our events.

Refreshing our page we get to see that all of those recurring events are being displayed on those days so our recurring event for the Monday rule is generating new recurring event lines in every day and a one-time event displays in the same correct place as well. That's pretty cool. It looks like our links there are not correctly working so let's go into the event's index and let's try changing this <%= link_to event.name, event_path(event.id) %> We'll explicitly set that id and because it wasn't persisted that id was not being added, so there we want to add event.id because this is not a permanent record in the database, so the link_to said: Let's not link it anywhere because it hasn't been saved yet, so by expressing this specifically, we can go create a link to that recurring event. Each of these recurring events will always point to event /9 and that is coming from those records that aren't actually saved, so this recurring event record here that is number nine is that actually really in the view, it's actually just the ones that are just created from our calendar array events, so this does correctly set those up. If we wanted to create a new one that was maybe weekly every Tuesday Thursday we can add that, and now we have it automatically on every Tuesday and Thursday, and if we change months of the calendar we'll see that his actually includes those as well and our one time event only shows up in November because that is the day that it was supposed to be.

This goes without saying, but your recurring events can get massively more complex if you want to go add a bunch more features to this. I've implemented the bare minimum that you need and one of the flaws with this is that what happens is that your events happen all the way into the past and all the way into the future infinitely. So our event, even though we created it November it actually goes back into the past as long as you can remember. It's not likely that you want to do that with your views like that, you generally want to say: When I create it any time in the future we want to display it but not everything in the past, and that's what Google Calendar does. There's a lot of other tweaks you can make like changing the schedule start to do the one time that you typed in when you created that record, and changing that will allow us to say: Well it starts November 14 and it happens anytime into the future and not into the past. That's nice for you to be able to customize and you can go as deep into this as you would like, so take a look at ice_cubes schedule and it's rules and how to query for those, and learn about that because once you've got the rules in there you can query this and make changes to it in order to get the output that you want in your views, so this loop is really simple, you just give it a start and an end and it will generate the occurrences between those days.

There are a ton of changes you probably want to make to this for your specific application, you might want to edit your controller so it queries for all the one-time events in that window, and then it finds all the recurring events that will be in that window as well so those are going to be important for doing a little bit of efficiency and only loading the events you need for the calendar view on that month or week or whatever it is. It's up to you to make some of those tweaks but this is a really good foundation to get you started in all of that and I will be back next week with an addition to this to talk about adding exceptions so we can have recurring events into the future but we remove a holiday and so we don't do a meeting on the holiday. We'll talk about that in the next episode, till then. I will talk to you later. Peace ✌️

Transcript written by Miguel

Discussion


Gravatar
Francisco Quinones (7,230 XP) on

On my app I use a Event and a EventTrans model so for each time a event occurs we can have a record with info and other stuff. So for the Calendar we display the First event with the recurrent on the fly and each time the event pass we save it as a record or if we need to move only on recurrent we create it as a event transaction.

My only problem is the time it takes to check if a recurrent is create as event_trans or not for calendar view.


Gravatar
Liz Bayardelle (100 XP) on

For some reason the simple_calendar gem messed with a bunch of the CSS on my site (e.g. all my links now have a black background on hover, etc.). Any hints on how to avoid this?

Gravatar
Chris Oliver (159,840 XP) on

simple_calendar doesn't include any CSS for the links, so it must come from somewhere else. It only has some optional CSS for styling the calendar, but not links.

Sounds like you may have created a scaffold and the scaffold.css file is styling your links.

Gravatar
Liz Bayardelle (100 XP) on

Gaaahhh! Thank you! That was driving me crazy.


Gravatar
Alex Musayev on

Thanks for a good demo for IceCube! I've been looking for this functionality to use it in one of my pet projects, but ended up with implementing something like this using cron format to represent recurrent event rules, and parse-cron gem to parse it. But now I'll try to switch to IceCube. I like the hash format it uses. It look less cryptic than crontab. Thanks for a good tip :)


Gravatar
Jorg Dominguez on

Waiting for the next episode!!!
You are great!


Gravatar
Josh Cooper (8,360 XP) on

Really enjoying this series. Curious to when the next episode will be for exceptions etc?

Gravatar
Chris Oliver (159,840 XP) on

Hey Josh, looks like I forgot to put the video in the series. You can find the episode here: https://gorails.com/episode...

Gravatar
Josh Cooper (8,360 XP) on

Fantastic! Thanks @excid3:disqus much appreciated. Are you looking to extend on this gem any further or do any further Vlog's?
Keen to have another view for possible listing and also if we needed to associate another model to a event recurrence?

Cheers
Josh

Gravatar
Chris Oliver (159,840 XP) on

Um possibly. I just made a tiny tweak today that improved performance by like 4x or something. Thought that might be useful to talk about, but I think I've covered memoization before.

What do you mean by "view for possible listing"? And if you want to do two models, you could combine the query results into what you pass in for the events array and as long as they have the "start_time" interface, it doesn't care what type of objects they are.

Gravatar
Josh Cooper (8,360 XP) on

Hi @excid3:disqus looking to try and implement views like this https://github.com/Serhioro...
That's what I mean by "view for possible listing?"

Also when you click on Week, Month etc it provide Event summary like number of events in that month/week.

Would be a good for extending on this series also :)
https://uploads.disquscdn.c...

Gravatar
Chris Oliver (159,840 XP) on

Ah yeah, that's what I figured. I'll consider it, one of the reason I haven't is that I didn't want any JS to be required. You could easily just make a route for each of those and them add your own buttons to each of the views and turbolinks would make navigating between those pretty snappy.

It does probably make sense to add day and year views to the calendar and leave it up to you to link them up on the page.


Gravatar
Neil Watt (70 XP) on

Hi, thanks for the tutorial I found it very helpful. I wondered if you had any thoughts on the best way to query recurring events that have been serialised using the recurring gem? The issue I'm running into is if an individual who has not created the original calendar events wanted to search several calendars (created by other users) for free slots at given times the serialiser method then seems to become more complex than if we'd created individual discrete events. For example if you wanted to share calendars between different users. Would be interested to know your thoughts on this. Thanks

Gravatar
Chris Oliver (159,840 XP) on

Depends on what you're trying to accomplish (like everything right? lol). What kind of search are you trying to pull off?

Gravatar
Neil Watt (70 XP) on

Thanks for your reply. Lol a little bit. I'm trying to make a basic scheduling app so that different users can search each others calendar schedules for available time slots and arrange meetings. From the user's standpoint; each user adds events to their calendar which take up a time slot. Their availability is then determined to be those time slots with no events (in practice slightly more complex than this but for now this is what I'm looking at). Then the user's search (of another user's calendar) would be something like:

"All time slots (for the another user) from datetime x to datetime y across one calendar with no events present".

The purpose would be to schedule a meeting for instance. The way I thought of the problem was to put all events matching given search date range criteria into an array that would appear as "search results". This would avoid the necessity of going into every user's calendar manually to check availability that way. It's quite simple to do that with discrete non-recurring events as you are querying discrete objects out of a database. But because the serialise methodology is not generating all the events in a series at once unless you load up the calendar for each user it seems a little more complex to do that.

Gravatar
Chris Oliver (159,840 XP) on

Ah yeah, that's a pretty complex one. I think what I would probably do is this:

1. Query for all the individual events
2. Query for all the recurring events within that range. This would match recurring events where the start date is before the end of the query and end dates of null (infinitely recurring) or end date after the beginning of the query.

Then I'd load those recurring events into ice_cube and then run the generator to create all the events for the recurring ones within your query range.

3. Then you can combine those two arrays of events into one and your view will have an accurate set of event objects to use for that query range.

Obviously, it's not as simple as the alternative of making recurring events just insert regular event records into the database. That may be good enough for your solution, or you may want truly infinite recurring events which would require you to build a bit more of a complex query system like above.

Gravatar
Neil Watt (70 XP) on

Thanks, much appreciated that makes sense I'll give it a shot. I'm realising I have to weigh up the advantage of saving space by accessing serialised events versus the convenience of querying actual event records in DB. I really liked the way you used recurrence select with ice cube and wanted to keep that approach.

Gravatar
Chris Oliver (159,840 XP) on

Yeah, I can see going both ones. On one hand, the developer effort is easier to just generate and save the recurring events but it makes the user experience a lot tougher (how do remove them all? what if you want to edit all of them? what about just one?). And then on the other hand, it's easier for the user, but tougher on you as a developer to build the ice_cube style recurring events and query for those.

You'll probably be fine either approach you take, just remember it can always be changed and improved later on so it all depends on what business needs are most important. :D

Gravatar
Neil Watt (70 XP) on

Sorry to bother you again but would really appreciate any tips of adapting this. I was trying to adapt your calendar_events method for use in saving all the instances of a recurring event from the events controller create method. Any idea how I could adapt this to generate and save all recurring events for a new instance of an event? My create method in event controller code is below. I think I just need to tweak it a little as it works for all the existing saved events but i specifically just want it to pass the new event through to the method.

My aim is this: user creates new event with recurrence rule, then on save it loops through all occurrences of this instance up until some predefined end time creating an array of the associated events which are subsequently saved after being generated. The difference here is that rather than fetching all the already saved events and looping through the occurrences, I want to do that only for the new instance that the user is creating when they open up the form.
/////events controller code

def create
## first testing for whether recurring is null or not
#room have one to many relationship to events

if params[:event][:recurring] == "null"

room = Room.find(params[:room_id])
@event = room.events.new(event_params)
@event.save
else

room = Room.find(params[:room_id])
#this loops through all already saved events and #then creates an array of occurances for each saved #event whereas I am trying to do this for only the #instance of the event being created not every event #already saved

@calendar_events = room.events.flat_map{ e|e.calendar_events(params.fetch(:start,Time.zone.now).to_date)}
@calendar_events.each(&:save)

end

end

///event modelcode

#I made a modification to the calendar_events method to account for one to many relationshi with rooms to events, and I am using start instead of start_date

def calendar_events(startit)
if recurring.empty?
[self]
else

end_date = startit.end_of_month.end_of_week

schedule(start).occurrences(end_date).map do |date|

@event = room.events.new(room_id: room_id, title: title, start: date, end: date, color: color, recurring: recurring)

end
end
end

Gravatar
Chris Oliver (159,840 XP) on

This isn't too bad. You would probably be well served to move your code for creating the individual events to the model itself so that this logic doesn't clutter the controller.

It looks like you're on the right track. I think you can clean a lot of this up like so: https://gist.github.com/exc...

This is untested, but I think the only issue with this would be that you would need to probably skip that first event in the recurring loop as it probably would duplicate the initial event.

Gravatar
Neil Watt (70 XP) on

Thanks again, much appreciated I'll try that. Cheers


Gravatar
Shawn Nigel Rebello on

Thanks a lot for this !


Login or create an account to join the conversation.