Awwwards
Building a Calendar for a Web App without Reinventing the Wheel's Hero Image

Building a Calendar for a Web App without Reinventing the Wheel

We're currently building Helix and one of the core features is calendaring and scheduling for managing your people, contacts, customers, employees, etc. through time. Yes, this is basically just a calendar but we wanted to do some different things with it. I'm going to walk you through some of our decisions and how we arrived at our current implementation and then explore how we might migrate to a more traditional and robust calendaring system.

Roll Your Own (RYO)

Much like a CMS, most everybody initially starts to roll their own calendar or "events" feature. I think this happens for a couple reasons. One, I think we as developers like to think that we can solve any problem with code (and we can!) and a calendar seems so simplistic that it must be child's play to build. Or two, we want to be able to own every aspect of the app we're building. I fell into both camps.

For starters, this feature was simply going to be a system of events and reminders. Let's say you wanted to schedule a call with a client at 1pm and a one-on-one with a colleague at 2pm. Single table, some associations, a queue job to send notifications, simple enough, right? Well, then Scope Creep (R) stumbled in the door and said, "Yeah but what about recurring events? Oh and event invitations? Don't forget about being able to subscribe to a colleague's or a team's calendar!" before lurking away. Those were all valid concerns, feature requests and, more importantly, questions we needed to answer before we built something that wouldn't get the job done.

We took a step back and looked at what Helix is supposed to do. Helix is supposed to equip its users with a unified set of tools to accomplish their jobs. We realized that a simple list of events was not in that toolset.

Luckily, the RYO was stopped in its tracks during the design phase.

External API

Part of making design and business decisions is recognizing when to build, when to outsource, when to hook into an API, and when to cut features. We have been doing all of these things with Helix and it's a skill that needs practice, refinement, and confidence to implement. So, the logical next step was to hook into an external API that has already figured out the calendar thing. We looked at the Google Calendar API and I started toying around with it and getting excited because it was solved some of the problems we ran into with the RYO. The downside? The event data would all be sent to a single Google account. Helix is multi-tenant app so you can imagine what would happen with all of those users sharing a single calendar. Ok but what if we created a new calendar for each tenant? Or, better yet, what if we created a new calendar for each user? Yeah, that's doable, but again, it's all one account and it just felt "off".

Hosted Calendar

Google is starting to sound like a less viable option, but we're still ok with talking to an external service for the calendar backend. What if we just made it an add-on that you could enable if you had, say, an Office 365 account? Well that would probably mean we'd need to support a Google backend for those tenants using Google Apps (the G suite?). And what about the umpteen other calendar providers? You know what! We should just host our own! It's the same thing as talking to Google or Office 365 but we could host it ourselves!

This is the point in the story where we found the sabre/dav package and I began reading up on the WebDAV (RFC 4918), CalDAV (RFC 4791), and iCalendar (RFC 5545) RFCs. Turns out this calendaring thing has been figured out for a long time and there are whole parts of the HTTP spec that deal with calendaring. Who knew?

Embedded Calendar

Of course, my next thought was concerned with managing another server just for calendaring. Sure, that would mean that our users could use their smartphone to connect to the Helix CalDAV server but then we'd have to build Helix as a full-fledged CalDAV client. I had a hare-brained idea that I could just composer require sabre/dav in the Helix project (which is a SaaS app built with Spark and Laravel), build the database schema with a few php artisan make:migration commands and start accepting calendaring requests! It's all housed in the same code base, the CalDAV tables are next to my application tables in the database, and I could run queries that included detailed calendaring information. It would be perfect.

Except it wasn't.

One, Laravel doesn't exactly play nice with the WebDAV extensions to HTTP. Here's how I would accept CalDAV requests in a Laravel app.

// File: routes/web.php


<?php


Route::match([
    'GET',
    'HEAD',
    'POST',
    'PUT',
    'PATCH',
    'DELETE',
    'OPTIONS',
    'PROPFIND',
    'PROPPATCH',
    'MKCOL',
    'COPY',
    'MOVE',
    'LOCK',
    'UNLOCK'
], '/dav{all}', 'DavController@index')->where('all', '.*');

Where DavController has the following...



// File: DavController.php


<?php


namespace App\Http\Controllers;


use App\MyBasicAuth;
use App\MyCalendarBackend;
use Illuminate\Http\Request;
use App\MyPrincipalBackend;


class DavController extends Controller
{
    public function index(Request $request)
    {
        $principalBackend = new MyPrincipalBackend;
        $calendarBackend = new MyCalendarBackend;
        $authPlugin = new \Sabre\DAV\Auth\Plugin(new MyBasicAuth(), 'SabreDAV');
        $tree = [
            new \Sabre\CalDAV\Principal\Collection($principalBackend),
            new \Sabre\CalDAV\CalendarRoot($principalBackend, $calendarBackend)
        ];




        $server = new \Sabre\DAV\Server($tree);
        $server->setBaseUri('/dav/');
        $server->addPlugin($authPlugin);


        $server->addPlugin(new \Sabre\DAVACL\Plugin());
        $server->addPlugin(new \Sabre\CalDAV\Plugin());
        $server->addPlugin(new \Sabre\DAV\Browser\Plugin());


        $server->exec();
    }
}

This worked until I turned on Authentication. The sabre/dav project uses either Basic Auth or Digest Auth and I opted for Basic. I tried but I couldn't get Laravel to mimic a Basic Auth request to the helix.dev/dav route. It worked fine in cURL and Postman but I never got authenticated from a request in the Laravel app itself.

It was also a wild departure from the structure and behavior of the rest of our app. Everything else uses a JSON API to fetch and manipulate data and this felt like introducing a huge headache for relatively little gain. One of our goals with Helix is to enable people and businesses to operate more efficiently so that they don't need their work calendar on their phone at all times. Part of equipping people with the right tools is enabling people to step back from working all the damn time.

So if we weren't going to host our own CalDAV or embed one within the Helix application code base, how were we going to solve this problem? What if we rolled our own solution...

RYO Part 2

This time around, armed with an intimate knowledge of RFCs 4918, 4791, and especially 5545, I set out to rethink our calendaring system. We would not allow for any WebDAV or CalDAV compliance. In the name of YAGNI, I couldn't justify spending more time or money on implementing a full-featured CalDAV system. What I was very interested in, however, was the idea of the iCalendar specification outlined in RFC 5545 providing the necessary structure for calendars and events.

The most complicated part is the recurrence rule because if an event repeats over time, it is only stored in the database as one event with an RRULE. So, a calendar object has to take that single event, parse the RRULE, and figure out how many occurrences to generate. Sabre has a VObject library (sabre/vobject) that contains classes for many Cal- Card- and WebDAV objects but we only care about the VCalendar and VEvent for right now. We'll introduce the VAlarm and maybe the VTodo as Helix gets further into development. The next hurdle was generating a compliant RRULE that the VCalendar object would understand. I found this one, aptly named php-rrule. With this library I can pass it an array of rules and it will generate an RFC-5545 compliant RRULE. I'll use this to create a VEvent and add that to a VCalendar object. Then, I can serialize the VCalendar object and store it in the database as the raw calendar data.

Here's what that looks like...

<?php


use App\Calendar\Calendar;
use App\Calendar\Event;
use Carbon\Carbon;
use Carbon\CarbonInterval;
use RRule\RRule;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\DateTimeParser;


trait HasCalendar
{
	public function addEvent($data, $calendarId = null)
	{
	    if (!$calendarId) {
	        $calendarId = $this->calendar->id;
	    }
	
	    if (!$this->sanitizeEventData($data)) {
	        // Bad starts_at/duration information. Bail.
	        return false;
	    }
	
	    $this->generateRRule($data);
	
	    $event = new Event();
	
	    $event->calendar_id = $calendarId;
	    $event->team_id = $this->team_id;
	    $event->summary = $data['summary'];
	    $event->description = $data['description'] ?? null;
	    $event->starts_at = $data['starts_at'];
	    $event->duration = $data['duration'];
	    $event->ends_at = $data['ends_at'];
	    $event->last_occurrence = $data['last_occurrence'];
	    $event->data = $this->buildVEventData($data);
	    $event->save();
	
	    // broadcast(new EventCreated($event));
	
	    return $event;
	}
	
	/**
	 * Sanitizes incoming dates...
	 *
	 * @param $data
	 * @return bool
	 */
	protected function sanitizeEventData(&$data)
	{
	    if (!isset($data['starts_at'], $data['duration'])) {
	        return false;
	    }
	
	    if (!$data['starts_at'] instanceof Carbon) {
	        $data['starts_at'] = Carbon::parse($data['starts_at']);
	    }
	
	    $duration = CarbonInterval::instance(DateTimeParser::parseDuration($data['duration']));
	
	    $data['ends_at'] = $data['starts_at']->copy()->add($duration);
	
	    $data['last_occurrence'] = $data['starts_at'];
	
	    return true;
	}
	
	/**
     * Generates RRULE if $data['recurring'] is given
     *
     * @param $data
     */
    protected function generateRRule(&$data)
    {
        if (!isset($data['recurring'])) {
            return;
        }


        $rrule = new RRule($data['recurring']);


        if ($rrule->isFinite()) {
            $data['last_occurrence'] =  Carbon::instance(last($rrule->getOccurrences()));
        } else {
            $data['last_occurrence'] = null;
        }


        $rruleData = $rrule->getRule();


        /*
         * VEVENT doesn't like the RRULE
         * having a DTSTART...
         */
        unset($rruleData['DTSTART']);


        /*
         * VCALENDAR throws a fit when trying $vCalendar->expand() if VALUES are null...
         */
        foreach ($rruleData as $key => $value) {
            if ($value === null) {
                unset ($rruleData[$key]);
            }
        }


        $data['rrule'] = $rruleData;
    }
    
    /**
     * Generates vEvent data.
     *
     * @param $data
     * @return string
     */
    protected function buildVEventData($data)
    {
        $vEventData = [
            'SUMMARY' => $data['summary'],
            'DTSTART' => $data['starts_at'],
            'DURATION' => $data['duration'],
        ];


        if (isset($data['description'])) {
            $vEventData['DESCRIPTION'] = $data['description'];
        }


        if (isset($data['rrule'])) {
            $vEventData['RRULE'] = $data['rrule'];
        }


        $vCalendar = new VCalendar(['VEVENT' => $vEventData]);


        return $vCalendar->serialize();
    }
}

Now, all I need to do to get the iCalendar compliant Calendar object is $vCalendar = \Sabre\VObject\Reader::read($event->data, \Sabre\VObject\Reader::OPTION_FORGIVING);. If I wanted to expand any recurring events (remember, they're only stored as single objects), I can just do $expanded = $vCalendar->expand($begin, $end); where $begin and $end are DateTime objects defining the range you want the VCalendar to expand.

This approach has its trade-offs, of course. This means that each App\Event has its own VCalendar and doesn't know anything about its siblings (those App\Event objects that belong to the same App\Calendar object). It also means that fetching a busy calendar could potentially take up a lot of time to fetch, parse, expand, merge, and return the "full" calendar. It also means that full CalDAV integration is impossible. We can, however, export the VEvents as .ics files that can be added to personal calendars outside of Helix.

We're finalizing the backend and building out tests and controllers to make this possible and the initial tests are all green and returning desired output.

Next Steps

How can we improve on this design? The logical end-game is to have a full, WebDAV compliant server that can handle the calendaring and contacts that can be accessed with any Cal- or CardDAV client. Before we get there, however, we can take steps to improve on the current design.

One of the first things we'll do is introduce a $calendar->data attribute that will contain a serialized VCalendar, but instead of just one VEvent, it'll have all of the VEvents from all of the App\Event objects that belong to it. When an App\Event is updated, we'll take the updated VEvent data and insert it into the VCalendar data of the parent App\Calendar. Then it'll just be a matter of expanding the $calendar->data instead of each individual $event->data.

The next improvement will be to go ahead and run the expansion and then cache the result into a cache table for quick retrieval.

Another step we'll take is to issue a queue job to update the App\Calendar $data instead of trying to do it in time with the request. We could also issue a queue job to update the cached calendar object. We would also make sure that when a queue job starts, it checks the queue in front of it and removes any jobs that are updating the same calendar. Since we would be blowing out the old values in place of newly generated ones and not updating-in-place, it wouldn't make sense to execute every queue job in order. It's just the most recent queue job that matters.

And finally, since we're storing everything as iCalendar, RFC-5545 compliant, migrating to an actual CalDAV server will be trivial.

Final Thoughts

We really ran the gauntlet on figuring out in which direction was the best to go at this time. I think we landed on a fine solution that will carry us far enough into the future, with enough improvements that we can make without throwing the baby out with the bath water. When the time comes, we can, hopefully, migrate quickly and easily to a proper WebDAV server with full Cal- and CardDAV support.


Patrick Guevara's Profile Picture

Patrick Guevara


Chief Software Engineer

Patrick cofounded Metric Loop on the dream of building really great software with a clean, transparent approach. He lives in Austin, Texas with his wife Jess and their dog named Moose.