Building an open-source Calendly alternative in Phoenix LiveView
8. července 2026 · 13 min čtení
A booking page is the most deceptively simple thing I have built. You share a link, someone picks a time, and it lands on both calendars. Point a designer at it and they will draw you the whole thing in an afternoon: a month grid, a list of slots, a form, a confirmation screen. I built that version early, and it worked. It demoed well.
Then I connected it to a real calendar. Then a second one, from a different provider. And the ground opened up.
The job underneath the form is to answer one question honestly: when is this person free? That means reading calendars that belong to Google, Microsoft, and a whole family of CalDAV servers. They agree on almost nothing, starting with what counts as busy. The LiveView the guest clicks through is the easy tenth of the work. This is about the other nine tenths.
Tymeslot is the scheduling tool I have been building in Elixir and Phoenix. I recently relicensed it from the Elastic License to AGPL-3.0, so the code below is open and you can read the parts I skip. The stack is Elixir, Phoenix, LiveView, Postgres, and Oban for background jobs, in an umbrella app.
So many calendars, so many definitions of busy
Start with the loudest disagreement. What makes an event count as busy?
Google puts it in a transparency field with two values, transparent or opaque, where transparent means "this is on my calendar, but do not treat me as busy". Microsoft has no transparency field. It has showAs, with six values: free, busy, tentative, oof, workingElsewhere, and unknown. CalDAV is a whole family of servers, all speaking iCal, and it splits the same fact across a TRANSP property and a separate STATUS. Three integrations, three different ways to answer one yes-or-no question.
And busy is only the loudest disagreement. The quieter ones pile up underneath it. An all-day event is a date; a timed event is a datetime with a timezone, and you cannot compare the two until you have decided what "does this all-day event overlap 3pm on Tuesday" even means. The end of an all-day event is exclusive, too, so a one-day holiday ends tomorrow, and if you read that literally you will hand out the day after every holiday. A meeting has a state, and only some states block you: confirmed does, cancelled does not, and an invitation you declined should leave you free, but each provider records that you declined it somewhere different. Recurrence is a small project on its own: a weekly stand-up is one event, a rule that says "weekly on Tuesdays", a list of dates it was skipped, and the odd instance someone dragged to a Thursday, and you have to expand all of that to know whether next Tuesday is free.
The bug where the calendar and the booking disagreed
The first version handled this the way you would expect. Each provider turned its API response into a loose map of fields, and the code that worked out your free slots filtered those maps. When I added free/busy handling, so that an event you had marked "free" would not block anyone, I put the filter in the obvious place: the code that draws your booking page. It worked. The page stopped treating "free" holds as busy.
What I did not do was add the same rule to the second piece of code that also decides availability, the check that runs when someone actually submits a booking. Those were two different functions, written at different times, and for a while they agreed often enough that nothing looked wrong.
Then someone blocked out a two-week holiday. They marked it free, one all-day event across the whole fortnight, exactly the case the "free" rule exists for. Their booking page showed every one of those days as open, correctly. And not a single slot on them could be booked. Every submission came back "no longer available". The code that drew the calendar knew the holiday did not count. The code that confirmed the booking had never been told. Same events, two code paths, two definitions of busy.
The fix for that specific case was one line. The problem was bigger: "what counts as busy" was a decision made in two places, and nothing made them agree. Transparency was just the rule that happened to expose it. The next one would have been cancelled events, or declined invitations, or some provider's private flag, and it would have failed the same way, remembered in the path that draws the page and forgotten in the path that takes the booking.
One struct, and one definition of busy
So I gave availability a single definition. Every provider, however it talks, now normalises its events into one validated struct at the boundary, and nothing downstream ever handles a Google event or an Outlook event or a CalDAV event again. It handles a CalendarEvent:
@type t :: %__MODULE__{
provider: :google | :outlook | :caldav,
all_day: boolean(),
start_date: Date.t() | nil, # all-day events are dates
end_date: Date.t() | nil,
start_at: DateTime.t() | nil, # timed events are datetimes, with a zone
end_at: DateTime.t() | nil,
timezone: String.t() | nil,
transparency: :transparent | :opaque,
status: :confirmed | :tentative | :cancelled | :declined,
recurrence_rule: String.t() | nil, # an RRULE
recurrence_exceptions: [Date.t()], # dates the series skips
recurrence_id: String.t() | nil, # set when this row is a moved instance
# ...trimmed; the real struct has the rest of iCal
}
The comment on top of the struct states the design out loud: every event in the cache is an instance of this struct, providers produce them during normalisation, and validation happens at construction time, so downstream code can trust the shape. Provider-specific oddities survive in a metadata bag alongside the raw iCal, but the shape everything reasons about is this one, and it is always valid, because an event that cannot be normalised never gets built. Transparency used to be a string that one code path happened to check. It is now a field with a default that both paths share.
A skip and a move are different things, which is why both fields are there. An EXDATE says the series does not happen that day. A dragged instance is a separate event of its own, carrying a recurrence_id that points back at the occurrence it replaces, and it has to be expanded as its own row. Miss that and next Tuesday keeps the slot the meeting has already left.
The mapping happens once, at the edge, per provider. Microsoft's six words for busy come down to a single line, in the provider module that assembles the pre-normalisation map:
# outlook/provider.ex: string fields, before they become atoms
transparency: if(outlook_event[:show_as] == "free", do: "transparent", else: "opaque")
Everything that is not free is busy, unknown included. Guessing busy costs someone a slot they could have had. Guessing free hands out a slot that is already taken, which is the one that produces an email starting "sorry, I'm actually".
Recurrence is harder to collapse. Google and CalDAV both hand you an RFC 5545 RRULE string; Graph will not accept one and insists on a structured pattern/range pair. So the canonical field stays an RRULE and Outlook gets a converter in both directions, which took a module. It stops where the recurrence editor stops: daily, weekly with BYDAY, monthly and yearly anchored on the start date. Graph's relative patterns, third Tuesday and the like, are out of scope on purpose. A converter that guesses at a rule it cannot express will be wrong on someone's standup eventually, and silently.
And "does this event block this slot", the question the holiday bug got two different answers to, now has one answer in one place:
def blocking?(%__MODULE__{status: status}) when status in [:cancelled, :declined], do: false
def blocking?(%__MODULE__{transparency: :transparent}), do: false
def blocking?(%__MODULE__{}), do: true
def blocking?(%{status: status}) when status in ["cancelled", "declined"], do: false
def blocking?(%{transparency: "transparent"}), do: false
def blocking?(%{}), do: true
That is the entire definition. If it is neither cancelled nor declined and nobody marked it free, it counts. The code that draws the calendar and the code that validates a booking both call blocking?/1, so the holiday bug has nowhere left to sit.
That is two shapes, and I would rather it were one. The struct heads are for the normalised path. The string heads take the plain maps the fresh-from-OAuth fetch hands over before normalisation, which is where that Outlook line above ends up. So the rule lives in one module, in two clause heads that a careless edit could still teach different things. What holds them together for now is that they sit six lines apart and every case is asserted twice, once per shape. The version I want normalises at the OAuth boundary too and deletes the second set.
To make sure that whole class of bug stays dead, a property test now generates calendars across awkward timezones, half-hour offsets and overlapping events, and asserts the one invariant the holiday broke: anything the calendar offers must actually be bookable. A slot you can see and cannot book is precisely the failure it exists to catch.
Keeping the cache honest
A single definition of busy is only worth anything if the events you hold are current, and calendars move without telling you.
Google and Microsoft can push changes: you register a subscription, they call your webhook when something moves. CalDAV, mostly, cannot, so it gets tiered polling. Servers that support a sync-token get a cheap "what changed since this token". Older ones that only offer a change tag get a light "did anything change at all" before a full pull. The rest fall back to fetching everything. It runs on Oban crons, staggered in small batches a second apart, so a few thousand connected accounts do not all hammer their calendar servers on the same tick.
The push side has a quiet failure mode of its own. A subscription expires, the calendar stops telling you about changes, and everything keeps working right up until it is subtly wrong, because you are computing availability against a calendar you stopped hearing from. A silently dead webhook is worse than no webhook, so a nightly job renews subscriptions before they lapse and another watches for channels that have gone quiet.
Sync also runs the other way, and that is where it gets careful. When someone deletes a meeting straight from their Google calendar, Tymeslot notices on the next pull and can cancel the booking and tell the host, rather than leaving a ghost. And when Tymeslot writes a booking to a CalDAV server, it replays its own pending changes before pulling remote ones, keyed on the ETags CalDAV hangs on every resource, so a remote pull can never quietly overwrite an edit it has not sent yet. A write that comes back with a failed If-Match is a real conflict, and for events Tymeslot owns it keeps its own version rather than guessing.
If you want the messiest corner, it was Apple, and the reason is worth explaining. When you ask a CalDAV server where your calendars live, it tells you, and every provider but one answers with a path on the server you are already talking to. iCloud answers with a full URL on a different host, a per-user partition like p110-caldav.icloud.com, and expects you to follow it there. That is exactly the move the code refuses to make. Letting a server hand you a fresh host and connecting to it is how a calendar integration quietly turns into a way to reach machines it has no business touching, so a server-supplied jump to an unvalidated host is not allowed. iCloud is the one provider that trips that rule in the course of working normally, so it gets handled on purpose: we keep the path it returns, discard the host it is trying to send us to, and stay on the address we already validated.
Validated, in this case, means checked once, when the account was connected, against the URL the user typed: every A and AAAA record resolved, and the whole thing refused if any of them lands in a private, loopback or link-local range, cloud metadata endpoint included. Self-hosters who really do run their CalDAV server on a private network opt back in with a config flag, because for them the dangerous address is the normal one.
This is stricter than the standard, and worth saying so. RFC 6764 bootstrap explicitly allows a service to point a client at a different host, so pinning the host refuses a move the spec permits. The cost lands on anyone whose calendars really do live behind a second DAV hostname: they have to type it themselves. I think that is the right trade here, but it is a trade.
The easy part is the demo; the product is the edge cases
Here is what the demo hides. The booking page a designer sketches is real, and it is maybe a tenth of the work. The other nine tenths is the pile of contradictions underneath that one calendar struct: two field names for busy that are really six values, dates that are not timestamps, a stand-up that is one event pretending to be fifty, a holiday that showed as free and blocked every booking, an Apple server that points you at a host you are not allowed to follow. None of that demos, and all of it is the product.
It is also why I wanted the thing open. The idea is the simple part; what took the time was that pile of edge cases, each one paid for by something breaking once. I moved Tymeslot from the Elastic License to AGPL-3.0 so it is open to read and to build on. AGPL suits a hosted product without pretending otherwise: the copyleft reaches software run over a network, so anyone offering Tymeslot as a service has to share their changes back, and the managed version stays viable while the code stays open.
What you self-host is the full scheduling core, the same engine the hosted version runs on, as one docker run with Postgres bundled. The managed service wraps that core in the commercial parts that pay for it, billing and the paid tiers. The scheduling, the sync and the struct above are open code.
The repo is at github.com/Tymeslot/tymeslot. If any of this was interesting, the real implementations have more corners than I have shown, and pull requests are genuinely welcome. And if you would rather someone else renewed the webhooks and rotated the tokens, there is a hosted version at tymeslot.app.