SevyDevy

Guide · Frontend

Design a Calendar Frontend: Recurring Events and Drag

26 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • A calendar frontend is the part of a calendar app that runs in the browser. It draws the week grid, shows repeating bookings and lets people drag a booking to a new time.
  • Store a repeating booking as one rule, such as "every Tuesday at 10 am", plus exceptions: dates that are skipped or moved. The browser turns the rule into blocks only for the week on screen.
  • Build dragging on pointer events, one set of browser events for mouse, pen and touch. Pointer capture keeps those events coming when a fast drag leaves the block. Snap to 15-minute slots and save only on release.
  • Dragging one block of a series must ask: this one, this and following, or all. Then save optimistically: show the move at once, and undo it with a reason if the server refuses.
  • Beginners can stop after the section on dragging one event from a series. Engineers then get the tested parts: a daylight saving bug that shifts a weekly booking for good, and version checks that stop silent overwrites. They also get browser support for Temporal, JavaScript's new date API, and the accessibility rule for dragging.

What goes wrong when a calendar frontend lets people drag bookings?

Drag-to-reschedule usually breaks because one block on the screen can stand for many bookings in the data. Moving that block can change one booking, every booking in the series, or none. A calendar frontend has to make that choice visible, and most first versions do not.

Picture a common setup. A booking web app serves small clinics and salons. Receptionists see a week grid of appointments and drag them to new times when patients or customers call. The setup is made up, but the four complaints below are the usual ones.

  • A physiotherapy clinic moved one Tuesday session to Wednesday. Every future Tuesday session for that patient moved too.
  • A salon dragged a haircut to 5 pm. Three seconds later it jumped back to 3 pm with no message at all.
  • A clinic in London saw its 10 am weekly booking show at 11 am after the clocks changed in March.
  • A receptionist who uses only a keyboard could not move any booking, because the grid worked only with a mouse.

Each complaint has a different cause: recurring events, saving, time zones and accessibility. This guide fixes them in that order, on the same app throughout.

What is a calendar frontend and how do you design one?

A calendar frontend is the browser code that fetches bookings, turns them into blocks on a time grid, and lets people create, move and resize them. Designing one means deciding where each piece of data lives and how changes flow back to the server.

The backend stores events and checks rules. The frontend has its own hard jobs. It must expand repeating rules into visible blocks, place overlapping blocks side by side, follow a finger or mouse smoothly, and stay correct when a save fails.

Four words come up again and again, so here they are in plain terms.

Series. A series is one booking that repeats, stored as a single rule. Example: "Physio for one patient, every Tuesday at 10 am, for 8 weeks" is one series.

Occurrence. An occurrence is one single date produced by the rule. The series above has 8 occurrences, one per Tuesday.

Exception. An exception is a change to one occurrence that the rule alone cannot describe. It can be a skipped date or a moved date.

Optimistic update. An optimistic update shows a change on screen before the server confirms it. If the server refuses, the screen goes back to the old state.

Here is the shape of the app's frontend. Start at the bookings API and follow the arrows to the grid, then follow the drag back out to the save queue.

Calendar frontend: rules in, moves out

Data arrives as rules and exceptions. Only the expander turns them into blocks, and only a drop sends a change back.

Diagram connections:

  • Bookings database to Bookings API (Rows for the week)
  • Bookings API to Client booking store (Series and exceptions)
  • Client booking store to Recurrence expander (Rules for this week)
  • Recurrence expander to Week grid (Blocks for this week)
  • Week grid to Drag controller (Pointer events)
  • Drag controller to Save queue (Move on release)
  • Save queue to Bookings API (Change plus version)
  • Bookings API: Sends series, exceptions and one-off bookings
  • Bookings database: Stores rules, not every future date
  • Client booking store: Holds server data plus pending changes
  • Recurrence expander: Turns rules into blocks for the visible week
  • Week grid: Seven day columns, 15-minute rows
  • Drag controller: Follows the pointer and snaps to slots
  • Save queue: Sends changes one at a time, with versions

Notice that the grid never talks to the API directly. Every block it draws comes from the store, through the expander. That one rule keeps the screen and the data from drifting apart.

How should a calendar frontend show recurring events?

Keep each recurring booking as one rule plus its exceptions, and expand it into blocks only for the dates on screen. Never copy every future occurrence into the browser, because a series with no end date never runs out.

Picture the weekly slot board at a turf football ground. A regular team has its slot painted on the board: "every Tuesday, 7 pm". One-off bookings are name magnets stuck on a single square.

When the team skips one Tuesday, the groundsman does not repaint the row. He puts a "not this week" magnet over that square. If they shift one week to Thursday, he adds a magnet on Thursday and covers Tuesday. The painted rule stays the same.

Where the picture breaks: the board shows one week and never changes its clocks. A real rule runs for months and crosses daylight saving changes, which is where the hardest bug in this guide lives.

Calendar data works the same way. RFC 5545, the iCalendar standard for calendar data, calls the painted rule an RRULE. A skipped date is an EXDATE. A moved occurrence is a separate entry with a RECURRENCE-ID.

The standard is precise about how these combine. DTSTART, the series' first start time, always counts as the first occurrence. The rule adds more dates, and any EXDATE start times are then removed. So apply exceptions after expanding the rule, never before.

Expanding a weekly rule in TypeScript

Here is a small expander for weekly series. It uses Temporal, the newer JavaScript date and time API, which understands named time zones. Where a browser lacks it, load a polyfill (code that adds a missing feature).

typescript
type WeeklySeries = {
  start: Temporal.ZonedDateTime; // first booking, in the clinic's time zone
  everyWeeks: number;
  until: Temporal.PlainDate; // last allowed date, inclusive
  skipped: Set<string>; // original dates to leave out, like "2026-11-03"
};

function blocksInRange(s: WeeklySeries, from: Temporal.PlainDate, to: Temporal.PlainDate) {
  const out: Temporal.ZonedDateTime[] = [];
  const cmp = Temporal.PlainDate.compare;
  const wall = s.start.toPlainDateTime(); // the clock time people agreed to
  for (let n = 0; ; n++) {
    const local = wall.add({ weeks: n * s.everyWeeks }); // always count from the start
    const day = local.toPlainDate();
    if (cmp(day, to) > 0 || cmp(day, s.until) > 0) break;
    if (cmp(day, from) < 0 || s.skipped.has(day.toString())) continue;
    out.push(local.toZonedDateTime(s.start.timeZoneId)); // a missing time moves forward
  }
  return out;
}

What just happened? The wall value is the clock time people agreed to, such as Tuesday 10 am. Each date is counted from the start, n weeks on, never from the previous date. The time zone section shows why that matters.

The skipped check is the EXDATE rule, applied after the date is generated. The until check is inclusive, like UNTIL, the standard's last allowed date for a rule. Last, toZonedDateTime turns the clock time back into a real moment in the clinic's zone.

Moved occurrences need one more step, not shown here. The expander also skips any date that has a moved entry, and the store adds that moved entry as an ordinary block.

How do you lay out overlapping events in a week view?

Group the blocks of one day into clusters that overlap in time, then split each cluster's width into columns. Each block takes the first column that is free when it starts.

A week view is a grid. Each day is a column, and each row is a 15-minute slot. In this app one hour is 72 pixels tall, so one minute is 1.2 pixels. A block's top is its start time in pixels, and its height is its length.

Trouble starts when two bookings overlap. A salon may have a haircut and a facial at 3 pm with different staff. If both blocks are drawn full width, one hides the other completely.

  1. Sort the day's blocks

    Sort by start time. When two start together, put the longer one first, so it gets the left column. Break exact ties by id.

  2. Build clusters

    Walk down the list. Keep adding blocks to the current cluster while each one starts before the cluster's latest end time.

  3. Pick a column for each block

    Inside a cluster, give each block the first column whose last block has already ended. Open a new column if none is free.

  4. Set widths

    Divide the day's width by the cluster's column count. A cluster with three columns gives each block one third of the width.

Here is that algorithm in TypeScript, with a busy afternoon at the salon. Times are minutes after midnight, so 15:00 is 900.

typescript
type Block = { id: string; start: number; end: number }; // minutes after midnight
type Placed = Block & { col: number; cols: number };

function layoutDay(blocks: Block[]): Placed[] {
  const len = (b: Block) => b.end - b.start;
  const sorted = [...blocks].sort(
    (a, b) => a.start - b.start || len(b) - len(a) || a.id.localeCompare(b.id), // id breaks exact ties
  );
  const placed: Placed[] = [];
  let cluster: Placed[] = [];
  let colEnds: number[] = []; // when the last block in each column ends
  let clusterEnd = -1;
  for (const b of sorted) {
    if (b.start >= clusterEnd) { // no overlap: close the old cluster
      for (const p of cluster) p.cols = colEnds.length;
      cluster = [];
      colEnds = [];
    }
    let col = colEnds.findIndex((end) => end <= b.start);
    if (col === -1) col = colEnds.length;
    colEnds[col] = b.end;
    const p = { ...b, col, cols: 0 };
    cluster.push(p);
    placed.push(p);
    clusterEnd = Math.max(clusterEnd, b.end);
  }
  for (const p of cluster) p.cols = colEnds.length;
  return placed;
}

const t = (hhmm: string) => +hhmm.slice(0, 2) * 60 + +hhmm.slice(3);
const day = [
  { id: "haircut", start: t("15:00"), end: t("15:45") },
  { id: "facial", start: t("15:00"), end: t("16:00") },
  { id: "colour", start: t("15:30"), end: t("16:30") },
  { id: "trim", start: t("16:00"), end: t("16:15") },
  { id: "massage", start: t("17:00"), end: t("18:00") },
];
for (const p of layoutDay(day)) console.log(`${p.id}: column ${p.col + 1} of ${p.cols}`);
text
facial: column 1 of 3
haircut: column 2 of 3
colour: column 3 of 3
trim: column 1 of 3
massage: column 1 of 1

What just happened? The facial is longer, so it takes column 1 at 3 pm. The colour starts at 3:30 while both earlier blocks are still running, so it opens column 3. The trim at 4 pm reuses column 1, because the facial ends exactly then.

The massage starts after everything else has ended, so it begins a new cluster and gets the full width. To draw a block, set its left edge to col divided by cols of the day's width, and its width to one over cols.

This layout is a pure function: the same blocks, in any order, always give the same positions. The id tie-break makes that hold for identical times. We ran it on 2,000 random days, and no two overlapping blocks ever shared a column. Run it again on each drag move, because one moved block can change a whole cluster.

One honest limit: every block in a cluster gets the same width, even one with empty space beside it. You can stretch such blocks into free columns later. For one week this stays cheap; a busy clinic might have 60 blocks a day (made up).

How does drag-to-reschedule work in the browser?

Drag-to-reschedule listens to pointer events, turns the pointer's position into a snapped time slot, shows a preview while moving, and saves only on release. Pointer events are one family of browser events that cover mouse, pen and touch.

The first rule is snapping. A receptionist never wants a 3:07 pm haircut, so every position rounds to the nearest 15 minutes. The block jumps in neat steps, which also tells the user that the grid is listening.

The second rule is pointer capture. Calling setPointerCapture on the block sends that pointer's later events to the block until it is released, even after the pointer leaves the block.

The naive version skips capture, and it looks fine with slow mouse moves. We ran both versions in headless Chromium 141. Without capture, a quick drag that left the block got no move events and missed the release, so the drag stayed stuck.

In the code below, block and col are the booking block and its day column. The showPreview and requestMove helpers are stand-ins for the app's own code.

typescript
const SLOT_MIN = 15;
const PX_PER_MIN = 1.2; // 72px for each hour
let drag: { id: string; grabMin: number } | null = null;

function slotAt(e: PointerEvent, col: HTMLElement): number {
  const y = e.clientY - col.getBoundingClientRect().top;
  return Math.round(Math.max(0, y / PX_PER_MIN) / SLOT_MIN) * SLOT_MIN;
}

block.addEventListener("pointerdown", (e) => {
  if (e.button !== 0) return; // main button or a touch only
  block.setPointerCapture(e.pointerId);
  drag = { id: block.dataset.id!, grabMin: slotAt(e, col) };
});
block.addEventListener("pointermove", (e) => {
  if (drag) showPreview(drag.id, slotAt(e, col) - drag.grabMin);
});
block.addEventListener("pointerup", (e) => {
  if (drag) requestMove(drag.id, slotAt(e, col) - drag.grabMin);
  drag = null;
});
block.addEventListener("pointercancel", () => {
  if (drag) showPreview(drag.id, 0); // put the preview back
  drag = null;
});

The slotAt function is the snapping. It turns pixels into minutes from the top of the day column, then rounds to the nearest 15. The grabMin value remembers where on the block the user grabbed it, so the block does not jump.

Notice that pointermove only calls showPreview. It changes the screen, never the data. Only pointerup calls requestMove, which starts the save. The Pointer Events standard says the browser releases capture by itself on pointerup, and our run saw exactly that.

To check yours, grab a block and drag it 90 pixels down, letting the pointer leave the block. At 1.2 pixels a minute, you should see one requestMove of 75 minutes. A listener on lostpointercapture should fire right after the release.

  • Set the CSS rule touch-action: none on blocks, so a finger drag moves the block instead of scrolling the page.
  • Move a light preview copy with a CSS transform, and leave the real block in place until the drop.
  • Wait for a few pixels of movement before starting a drag, so a simple tap still opens the booking.
  • Find the day column from the pointer's x position, so a block can move across days, not only up and down.
  • Let Escape cancel the drag the same way the pointercancel handler does, and put the preview back.

What happens when you drag one event from a recurring series?

The app must ask which bookings the move applies to: this one only, this and following, or all of them. Each answer changes different data, so the frontend cannot guess.

This was the physiotherapy bug. The old code always updated the series rule, so moving one Tuesday moved every Tuesday. Back at the turf ground, that is the groundsman repainting the whole row when the team wanted one week moved.

Follow the diagram from the drop. The question box is where a small dialog appears. One-off bookings skip it completely.

Dropping a block that may belong to a series

One-off bookings move straight away. Series bookings ask first, and each answer writes different data.

Diagram connections:

  • Block dropped on new slot to Part of a series?
  • Part of a series? to Move the one-off booking (No)
  • Part of a series? to Ask which bookings move (Yes)
  • Ask which bookings move to This one only (Only this one)
  • Ask which bookings move to This and following (This and later)
  • Ask which bookings move to All in the series (Every booking)
  • Block dropped on new slot: Preview ends, real move begins
  • Part of a series?: Check if the block came from a rule
  • Move the one-off booking: Change its start and end times
  • Ask which bookings move: Small dialog with three choices
  • This one only: Add a moved entry with RECURRENCE-ID
  • This and following: End old rule, start a new series here
  • All in the series: Change the rule's start time

"This and following" is the tricky one. RFC 5545 has a RANGE=THISANDFUTURE option, which applies one change to an occurrence and all later ones. A simpler path needs no RANGE support on either side.

End the old rule by setting its UNTIL just before the dragged occurrence's original start, and start a new series at the new time. The old series keeps its history, and the new one carries the change.

Some rules end by a count instead, such as 8 weeks, stored as COUNT. Replace COUNT with UNTIL on the old rule, and give the new series the count that is left. The standard forbids COUNT and UNTIL in one rule, and wants UNTIL in UTC (the world's reference clock) when the series has a time zone.

Splitting has a cost. Exceptions already saved for later dates still point at the old series. Move them to the new series, shifted by the same change, so a skipped Tuesday becomes a skipped Wednesday. A plain copy matches nothing, and a skipped Diwali week quietly comes back.

The three ways to move a recurring booking
ChoiceWhat changes in the dataWhat the receptionist seesMain risk
This one onlyOne new moved entry, found by its original start timeOnly this week's block movesMany moved entries make a series hard to read
This and followingOld rule gets an end date, a new series startsThis week and later weeks moveLater exceptions stay on the old series unless moved and shifted
All in the seriesThe rule's start time changesPast and future blocks all movePast visits now show the wrong time

The dialog should also show how many bookings each choice affects, such as "6 upcoming sessions". The receptionist then sees the size of the change before choosing.

How do you save a drag without making the user wait?

Show the moved block at once, send the change in the background with the booking's version number, and roll back with a clear message if the server refuses. This is an optimistic update.

Back at the turf ground, the groundsman slides the magnet to Thursday while the owner confirms on the phone. Usually the call goes fine. If another team already paid for Thursday, the magnet goes back, and he tells the captain why.

The silent jump-back came from skipping that last part. The server refused the move because another receptionist had booked 5 pm a moment earlier. The frontend undid the move but never said why.

Two different checks can refuse a move, and the message should name the right one. A slot check catches a clash with another booking, like the 5 pm booking another receptionist made for the same stylist. A version check catches someone else editing this same booking first.

Each booking has a version that changes on every edit. The frontend sends the version it last saw. If the server's copy is newer, it rejects the move, so nobody silently overwrites a colleague's change.

HTTP already has this pattern. The server labels each version with an ETag, and the client sends it back in an If-Match header. If it no longer matches, the server answers 412 Precondition Failed instead of saving.

Follow the save in the diagram below. Each box is one step, and the note says which part talks to which.

Saving a drag with an optimistic update

The screen changes first. Two server checks, slot and version, decide whether it stays, and a refusal comes back with its reason.

Diagram connections:

  • Drop on new slot to Show new time at once
  • Show new time at once to Send move with version
  • Send move with version to Slot free, version current?
  • Slot free, version current? to Mark as saved (Yes to both)
  • Slot free, version current? to Roll back and explain (No: slot taken or newer version)
  • Drop on new slot: Receptionist → Week grid
  • Show new time at once: Week grid → Client store
  • Send move with version: Save queue → Bookings API
  • Slot free, version current?: Bookings API → Database
  • Mark as saved: Bookings API → Client store
  • Roll back and explain: Bookings API → Client store

The rollback message now says what happened in plain words: "5 pm was just booked by another receptionist". It also offers the nearest free slot, so a confusing jump becomes a small, clear choice.

  • Keep each pending change separate from confirmed server data, so a rollback restores exactly the old state.
  • Show a small saving mark on the moved block until the server confirms it.
  • Send changes to the same booking one after another. Two saves sent together carry the same version, so the second one would be refused.
  • If the network is down, keep the change in the queue and say so, instead of rolling back at once.

How do you handle time zones in a calendar UI?

Store each series with its start time and a named time zone, such as Asia/Kolkata or Europe/London, and expand it in that zone. Convert to the viewer's zone only when drawing the block.

Those names come from the IANA time zone database, the shared list of every region's clock rules. A fixed offset like +05:30 is not enough, because it cannot tell you when the clocks change.

This was the London complaint. The old code saved the first booking as a fixed moment in UTC and added seven days of 24 hours each week.

We ran both approaches on a 10 am Tuesday series from 17 March 2026. UK clocks went forward on 29 March. From 31 March the fixed-hours version showed 11 am, while the expander kept 10 am.

People think of a weekly booking as clock time: 10 am on Tuesdays, local time. The expander adds weeks to that clock time, then places it in the zone, so 10 am stays 10 am.

The missing hour that moves a series for good

Daylight saving also creates odd local times. On the spring night, some clock times never happen. In London on 29 March 2026, 1:30 am did not exist. On the autumn night, one hour happens twice.

It shows 2:30 am, and so does every later week. On 29 March Temporal moved the missing 1:30 to 2:30, using its default rule called compatible. The naive loop then added a week to 2:30, and the shift never came back.

That is why the expander above counts from the start every time. Here is the same series run through it.

typescript
const nightShift: WeeklySeries = {
  start: Temporal.ZonedDateTime.from("2026-03-15T01:30[Europe/London]"),
  everyWeeks: 1,
  until: Temporal.PlainDate.from("2026-04-12"),
  skipped: new Set(),
};
const range = [Temporal.PlainDate.from("2026-03-01"), Temporal.PlainDate.from("2026-04-30")] as const;
for (const t of blocksInRange(nightShift, ...range)) console.log(t.toString());
text
2026-03-15T01:30:00+00:00[Europe/London]
2026-03-22T01:30:00+00:00[Europe/London]
2026-03-29T02:30:00+01:00[Europe/London]
2026-04-05T01:30:00+01:00[Europe/London]
2026-04-12T01:30:00+01:00[Europe/London]

What just happened? Only 29 March moves to 2:30, because 1:30 does not exist that night. From 5 April the shift is back at 1:30, now in summer time, which the +01:00 shows.

Should that missing 1:30 be shown at 2:30 or dropped? RFC 5545 reads both ways. One paragraph says such instances must be ignored. A later one says to read them like any date-time, using the offset before the gap, which also gives 2:30.

Temporal's default matches the shifting reading. To drop them instead, pass disambiguation "reject" to toZonedDateTime and skip the date when it throws a RangeError. Pick one rule, and make the server use the same one.

For a time that happens twice, Temporal's default picks the earlier one. That matches RFC 5545's rule for repeated local times. We ran both nights on two Temporal polyfills and got the same results.

India has no daylight saving, so a team testing only with Asia/Kolkata never sees these bugs. Add London and New York clinics to your test data, with series at 1:30 am and 10 am.

How do you make drag-to-reschedule accessible?

Give every drag action a second way that needs no dragging, such as a Move menu or arrow buttons, and make it work from the keyboard too. Dragging alone shuts out people who cannot hold and move a pointer.

WCAG 2.2, the main web accessibility standard, has a rule for this called Dragging Movements: success criterion 2.5.7, level AA. Anything done by dragging must also work with a single pointer and no dragging, unless dragging is essential.

The standard's own examples include a task board, where you select an item with a tap or click and move it with a menu or an arrow button. A calendar fits that pattern well. W3C notes this rule is separate from keyboard access, so you need both.

  • Make each block focusable, and let Enter open a menu with Move, Change length and Cancel booking.
  • In move mode, arrow up and down change the time by one slot, and left and right change the day.
  • Announce the new time as it changes, such as "Haircut, Thursday 5 pm", through a live region (a part of the page screen readers read out when it changes).
  • Let a tap on a block followed by a tap on an empty slot move the booking, with no drag at all.
  • Keep the series dialog reachable by keyboard, with the focus starting on "This one only".

To check it, put the mouse away and move one booking a day later with the keyboard alone. Then do the same with taps alone on a phone. Both should send the same requestMove as a drag.

How do you explain a calendar frontend design in a system design interview?

Start with the data shape, then walk through showing, dragging and saving, and finish with time zones and accessibility. Tie each choice to a bug it prevents.

  1. Ask about scope

    Which views are needed: day, week or month? Are there recurring events, many staff columns, or touch screens? Who else can edit the same calendar?

  2. Describe the data

    Series as one rule plus exceptions, one-off bookings as plain events, and every series tied to a named time zone.

  3. Draw the client parts

    API, client store, recurrence expander, week grid, drag controller and save queue. Say the grid draws only from the store.

  4. Explain layout and drag

    Overlap clusters with columns, pointer capture, snapping to slots, and a preview that does not change data until the drop.

  5. Cover recurring edits

    Ask this one, this and following, or all. Explain what each choice writes and what it can break.

  6. Cover saving and conflicts

    Optimistic update, a slot check and a version check, rollback with a reason, and a queue that waits out a weak network.

  7. Close with time zones and access

    Count weeks from the start in the series' zone, decide what happens to a missing hour, and give every drag a keyboard and tap path.

Leave the backend details short unless asked. Storing series, sending reminders and finding free slots are backend topics. Say so in one line, then come back to the browser.

Questions people ask

Should the server or the browser expand recurring events?
Both, for different jobs. For showing a week, the browser can expand them, because it needs only a few dates and paging stays fast. The server must still expand them for reminders, free-slot search and conflict checks. Both sides must follow the same rules, so share test cases, including the daylight saving nights.
What is the difference between EXDATE and RECURRENCE-ID?
EXDATE removes start times from a recurring series, so those occurrences do not happen. RECURRENCE-ID marks a separate entry that replaces one occurrence, for example with a new time. Its value is the occurrence's original start time, which is how the app knows which block to replace.
How do I build drag and drop for a calendar without a library?
Use pointer events with pointer capture, as the TypeScript in this guide shows. Turn the pointer's position into a snapped time slot, move a preview while dragging, and save once on release. A library helps with edge cases like auto-scrolling, but the core is short enough to understand first.
Why not use the HTML drag and drop API for a calendar?
You can, but it fits a time grid poorly. The HTML Drag and Drop API is built around draggable elements and drop targets, with a see-through copy following the pointer. Its events do carry the pointer's position, but the browser draws that copy itself, following the pointer rather than your 15-minute slots. Pointer events let you draw your own snapped preview, with one code path for mouse, pen and touch.
How should a calendar handle two people editing the same booking?
Let the server decide. Each booking carries a version, and every change sends the version the client last saw. Over HTTP this is an ETag sent back in an If-Match header. If it is out of date, the server answers 412 Precondition Failed, and the losing screen rolls back, explains and reloads.
How do I test a calendar frontend?
Test the pure parts with plain unit tests: the recurrence expander, the overlap layout and the snapping function. Include the spring and autumn nights in Europe/London. Then run browser tests that drag a block with a real pointer, drop it, and check both the screen and the request sent.
Does a calendar frontend need real-time updates?
It helps when several people edit one calendar, like receptionists at a busy clinic. A push channel can tell open screens that a booking changed, so they refresh before someone drags into a taken slot. The slot and version checks are still needed, because pushes can arrive late.

Sources and further reading

Practise this with Lala

Practise