All posts
engineering open-source

Building an open-source Calendly alternative in Phoenix LiveView

Luka Breitig — Software Engineer & AI Developer
Luka Breitig

8 July 2026 · 11 min read

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.

A scheduling tool's actual job is not drawing a form. It is answering one question, "when is this person free", and answering it honestly 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 five values: free, busy, tentative, oof, and workingElsewhere. CalDAV is not one system but a family of them, all speaking iCal, where the same fact is split across a TRANSP property and a separate STATUS. Three integrations, three completely 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. 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 did not. Same events, two code paths, two different definitions of busy.

The fix for that specific case was one line. The problem was not one line. The problem was that "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, 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 it was skipped
        # ...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 are not thrown away, they are tucked into a metadata bag and the raw iCal is kept, 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 stopped being a string that one code path happened to check and became a field, with a default, that both paths share.

The mapping happens once, at the edge, per provider. Microsoft's five words for busy come down to a single line:

transparency: if(outlook_event[:show_as] == "free", do: "transparent", else: "opaque")

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

That is the entire definition. Not cancelled or declined, not marked free, therefore it counts. The code that draws the calendar and the code that validates a booking both call blocking?/1. They cannot drift apart any more, because there is nothing to keep in sync: one rule, one place, one shape of data. (You will spot two versions of the predicate. One matches the struct; the other matches plain maps with string fields, which is what the fresh-from-OAuth path hands over before it is fully normalised. Same rule, two shapes of data, because the real world does not hand you structs.)

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 iCal etags, so a remote pull can never quietly overwrite an edit it has not sent yet. A precondition failure on one of those writes 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, drop the host it is trying to send us to, and stay on the address we already validated.

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 five 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 it demos. All of it is the product.

That is also, in the end, why I wanted it open. The value in a tool like this is not the idea, which is the simple part. It is 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, run, and improve 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, but the scheduling, the sync, and the struct above are the 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.

Ready to give Tymeslot a try?

Tymeslot is the open-source scheduling platform with a booking page worth sharing. Core scheduling is free, forever.

No credit card required
Pro plan: €9 / month
Setup in 5 minutes