Skip to content

9.7.27 — Calendar & Meeting Scheduler

"Design a calendar with recurring events, and a scheduler that finds a time everyone is free."

"Every Tuesday at 10:00, forever" is one row in a database and nine hundred meetings in a user's life. Then someone moves the one on 14 March to 11:00, deletes the one in the Christmas week, and changes the time of every meeting from April onwards. Those three edits are what this problem is really about, and a design that stores the meetings as rows cannot do any of them well.

There is a second half — finding a slot when eight people and a room are all free — and it turns out to be a much smaller problem than it looks, provided the first half was modelled correctly.

1. A recurring event is a rule, and the meetings are computed

The tempting design generates rows: create the series, write nine hundred Meeting rows, done. It fails in four separate ways and it is worth naming all four, because each one maps to a section below.

Forever has no end. "Every Tuesday, no end date" cannot be written out. Generating "enough" rows means picking an arbitrary horizon and having a background job extend it, which is a job that will silently stop working.

Editing the series means rewriting hundreds of rows. Change the time and every future row must be updated, in one transaction, while people are looking at them.

The rule is lost. Once expanded into rows, "every second Tuesday" is indistinguishable from nine hundred unrelated meetings that happen to fall on Tuesdays. Nothing can answer "what is the pattern?"

Time zone changes break the rows. When a country moves its clocks, every generated row after the change is an hour wrong, and there is no rule left to recompute them from.

So the series is stored as a rule and the meetings are computed on read:

typescript
interface EventSeries {
  id: SeriesId;
  organiserId: UserId;
  title: string;
  durationMinutes: number;          // (1)
  startLocal: LocalDateTime;        // (2) "2026-03-03T10:00", no zone in the value
  timeZone: IanaZone;               // (3) "Europe/London"
  recurrence: RecurrenceRule | null;// (4) null = a single meeting
}

interface RecurrenceRule {
  frequency: "daily" | "weekly" | "monthly" | "yearly";  // (5)
  interval: number;                 // (6) every N of those
  byWeekday?: Weekday[];            // (7)
  byMonthDay?: number[];            // (8)
  ends: { kind: "never" }           // (9)
      | { kind: "after"; count: number }
      | { kind: "onDate"; date: LocalDate };
}

(1) Duration rather than an end time, because a meeting that starts at 10:00 and lasts an hour stays an hour long on the day the clocks change. An end time of 11:00 does not survive that.

(2) The start is a local date and time with no zone baked into it. 2026-03-03T10:00 means "ten o'clock, wherever this meeting lives."

(3) The zone is stored separately and by name, not as an offset. Europe/London is +0 in winter and +1 in summer, and only the name knows that. Storing +00:00 freezes the meeting to a rule that changes twice a year.

(4) A single meeting is a series with no rule, so there is one shape for everything and no branching between two models.

(5) to (8) The vocabulary is small and covers almost everything people ask for: every 2 weeks on Tuesday and Thursday; every month on the 15th; every year. This is deliberately a restricted version of the standard calendar recurrence format that email and calendar programs already exchange, and using the same field names means importing and exporting is a mapping rather than a translation.

(9) The three endings, and never is the one that forces the whole design. A rule can be infinite; a table of rows cannot.

Expanding the rule is a generator over a bounded window, never over the whole series:

typescript
function* occurrences(
  s: EventSeries, from: LocalDate, to: LocalDate,
): Generator<Occurrence> {
  let cursor = s.startLocal;
  let produced = 0;

  while (cursor.date <= to) {                                   // (1)
    if (matchesRule(cursor, s.recurrence) && cursor.date >= from)
      yield { seriesId: s.id, originalStart: cursor, ... };     // (2)

    produced++;
    if (s.recurrence?.ends.kind === "after"
        && produced >= s.recurrence.ends.count) return;         // (3)

    cursor = advance(cursor, s.recurrence);                     // (4)
  }
}

(1) The loop is bounded by the window the caller asked for, which is always a month or a week of screen. An infinite series is not a problem when nobody ever asks for all of it.

(2) Each occurrence carries its original start, and that field is the key to everything in section 2. It is the occurrence's identity: not a row identifier, but "the one that the rule says begins at this local time."

(3) "After 10 occurrences" counts occurrences produced by the rule, not days elapsed, so the counter lives here rather than in the date arithmetic.

(4) Advancing is where the fiddly calendar arithmetic lives — "every month on the 31st" has to decide what to do in February — and isolating it in one function keeps that mess out of everything else. The usual rule, and the one most calendars use, is to skip months that have no such day rather than clamping to the 28th, because a monthly meeting silently moving to a different day of the month is worse than one being missed.

2. The three edits, and the field that makes them possible

Somebody changes one meeting in the series. There are exactly three things they can mean, every calendar offers all three, and each is implemented differently.

the rule: every Tuesday 10:003 Mar10 Mar14 Mar24 Mar31 Mar7 Apr14 Apr21 Apr① movedan override keyed byits original start② deletedan override markedcancelled — never amissing row③ this and followingthe old rule is ended herea new series takes overthe override is keyed by original start, not by the new timeIf the 14 March meeting is keyed by its new 11:00 time, then moving it again cannot find it, and expanding the rule stillproduces the original 10:00 meeting with nothing to suppress it — the user sees the meeting twice. The original start is theonly value both the rule and the override agree on, which is exactly why it is the identity.
Figure 1 — Three edits to one series. A moved occurrence and a deleted one are overrides keyed by original start. "This and following" is not an override at all — it ends the rule and starts a second series.
typescript
interface OccurrenceOverride {
  seriesId: SeriesId;
  originalStart: LocalDateTime;     // (1) the key — never the new time
  change:
    | { kind: "cancelled" }         // (2)
    | { kind: "moved"; newStart: LocalDateTime; newDuration?: number }
    | { kind: "edited"; title?: string; location?: string };
}

(1) The override is keyed by the occurrence's original start, and this is the single most important line on the page. It is the only value the rule and the override can both name. Key by the new time and expanding the rule still produces the original meeting with nothing to suppress it, so the user sees it twice — a bug every calendar implementation hits once.

(2) A deleted occurrence is an override that says "cancelled", never an absent row. Absence cannot be expressed by a rule that generates every Tuesday, so the cancellation must be a positive fact stored somewhere.

Expansion becomes: generate, then apply overrides.

typescript
function expand(s: EventSeries, ovs: OccurrenceOverride[], from, to) {
  const byKey = new Map(ovs.map(o => [key(o.originalStart), o]));  // (1)
  const out: Occurrence[] = [];

  for (const occ of occurrences(s, from.minus(SLACK), to.plus(SLACK))) {  // (2)
    const ov = byKey.get(key(occ.originalStart));
    if (ov?.change.kind === "cancelled") continue;                 // (3)
    out.push(ov ? applyOverride(occ, ov) : occ);
  }
  return out.filter(o => inWindow(o, from, to));                   // (4)
}

(1) Index the overrides by original start so the loop is a lookup rather than a scan.

(2) Expand a slightly wider window than asked for, then filter at the end. This is the subtle part: an occurrence whose original start was Monday could have been moved to Tuesday, so restricting the generator to exactly the requested window would miss it. Widening by a few days and filtering afterwards handles it in two lines. Getting this wrong produces the bug where a moved meeting disappears when you scroll to the month it was moved into.

(3) Cancelled occurrences are skipped after generation, which is the only place they can be skipped, because the rule does not know about them.

(4) Filter by the occurrence's final time, after any move has been applied.

"This and all following" is not an override at all, and treating it as one is the mistake worth avoiding. Writing an override for every future occurrence of an infinite series is impossible, and for a long finite one it is thousands of rows. Instead:

End the original rule the day before the split, by setting ends: { kind: "onDate" }.

Create a new series starting at the split with the new details, carrying the same attendees.

Move the overrides dated on or after the split to the new series, so a meeting that was already moved stays moved.

And a series with a single change to one occurrence must not be confused with a split. Users make this choice from a dialog with three buttons, and the dialog is only honest if all three are actually implemented — a calendar that silently applies "all events" when the user chose "this event" destroys work in a way people do not forgive.

3. Time zones, which is where most designs quietly break

The rule is short and the reasoning is not: store a local time plus a zone name for anything recurring, and an absolute instant for anything single and fixed.

Why recurring events cannot store instants. A standup at 09:00 in London is 09:00 in London in January and 09:00 in London in July, even though those are different absolute instants — the clocks moved. Convert to an instant when the series is created and the summer meetings all arrive an hour late. The user asked for "nine o'clock", not "09:00 UTC", and only the local time plus the zone preserves what they said.

Why the zone must be a name. Europe/London is a rule that says when the offset changes; +00:00 is a number that does not. Governments change these rules with a few months' notice — countries have abolished daylight saving, changed the switch dates, and changed zones outright. A calendar storing offsets is wrong the day a rule changes, with no way to recompute; a calendar storing names is corrected by an operating system update.

Two genuinely awkward cases that must have stated answers:

The time that does not exist. When clocks jump forward, 01:30 never happens. A meeting scheduled at 01:30 on that day has to go somewhere, and the standard choice is to push it forward by the size of the jump, to 02:30. Any consistent rule works; having no rule means a crash or a silently dropped meeting.

The time that happens twice. When clocks go back, 01:30 occurs twice. The standard choice is the first occurrence. Again, the requirement is a written-down rule, not a particular one.

And the question that has no single right answer, so it must be asked: whose zone owns the meeting? An organiser in London schedules a weekly call and then moves to Tokyo. Does the meeting follow them, or stay at 10:00 London for the six other attendees? Most calendars keep the series in its original zone — the meeting has a zone, the person does not — and that is defensible, but it must be a decision rather than an accident of the code.

Display is a separate concern from storage. Every attendee sees the meeting in their zone, converted at render time from the stored local time and zone. Nothing about the stored data changes when someone travels. Keeping conversion at the edge is what stops zone bugs spreading through the system.

4. Attendees, and why each one has their own answer

A meeting is not a shared object that everyone edits. It has one organiser and many attendees, and each attendee has a private answer and a private view.

typescript
interface Attendance {
  seriesId: SeriesId;
  originalStart: LocalDateTime | null;   // (1) null = the whole series
  userId: UserId;
  response: "needsAction" | "accepted" | "declined" | "tentative";  // (2)
  optional: boolean;                     // (3)
  visibility: "busy" | "free";           // (4)
}

(1) An attendance row usually covers the series, and null says so. But a person can decline one occurrence — "I am away that Tuesday" — and that is a row keyed by original start, exactly like an override. The same key appears again because it is the same idea: identifying one occurrence of a rule.

(2) Four responses, not a boolean. needsAction is genuinely different from declined, and an organiser looking at a meeting needs to see who has not answered separately from who said no.

(3) Optional attendees do not block scheduling. Section 5's slot search treats them as advisory, and forgetting this makes the search return nothing for any meeting with a large invite list.

(4) Someone can accept a meeting and still show as free — a large all-hands they may drop into. This one field is what stops an optional company meeting blocking everyone's calendar for an hour.

The organiser owns the meeting; attendees own their answers. Only the organiser can change the time, the title or the rule. An attendee changing "their copy" of the time is a feature calendars deliberately do not offer, because it produces two people confident about two different times.

Cancellation is a state, never a delete. A cancelled meeting must remain visible to attendees who need to know it is not happening. Deleting it makes the meeting disappear from their calendar with no explanation, which people read as a bug and act on as if the meeting were still on.

5. Finding a time everyone is free

This is the part that sounds hard and is not, provided the model above exists.

Step one: turn each person's calendar into a list of busy intervals. Expand every series in the window, apply overrides, drop anything they declined and anything marked free, and convert to absolute instants — because comparing across people means comparing across zones, and only instants are comparable.

Step two: merge overlapping intervals per person.

typescript
function merge(intervals: Interval[]): Interval[] {
  const sorted = [...intervals].sort((a, b) => cmp(a.start, b.start));  // (1)
  const out: Interval[] = [];

  for (const iv of sorted) {
    const last = out[out.length - 1];
    if (last && iv.start <= last.end) last.end = max(last.end, iv.end); // (2)
    else out.push({ ...iv });                                          // (3)
  }
  return out;
}

(1) Sort by start. Everything below depends on this, and it is the only sort in the algorithm.

(2) If this interval starts before the last one ended, they overlap, so extend the last one. max is needed because the new interval can be entirely inside the previous one, and taking iv.end blindly would shrink the busy period — a bug that hands out slots when someone is in a long meeting.

(3) Otherwise it is a new busy block.

Step three: combine everyone and invert. Merge all the required attendees' busy intervals together, and the gaps between them are the times everyone is free. Then filter to slots long enough for the meeting, and inside working hours — where "working hours" is per person and in their zone, which is the whole difficulty of scheduling across continents and is data rather than logic.

The complexity is not the problem people expect. For 8 people over 5 working days with maybe 40 meetings each, that is 320 intervals, sorted and swept once. It is microseconds. Scheduling is not a hard algorithmic problem; it is a modelling problem, and every difficulty in it comes from recurrence, overrides, zones and working hours rather than from the search.

Ranking the results is where the product lives. Any calendar can list free slots. A useful one prefers the earliest, avoids slots that fragment someone's day into useless twenty-minute gaps, avoids lunch, and prefers times where the optional attendees are also free. That preference function is the feature; the interval sweep is the plumbing.

6. Booking the room, which is the only contended resource

People do not conflict — two meetings at the same time is annoying and not incorrect. Rooms conflict, and a room double-booked is a real failure with two groups standing in a doorway.

So a room booking is a claim on a limited resource, and it is the same move as every other claim in this chapter:

sql
INSERT INTO room_bookings (room_id, during, series_id, original_start)
VALUES (:roomId, tstzrange(:start, :end), :seriesId, :originalStart);
-- with: EXCLUDE USING gist (room_id WITH =, during WITH &&)   -- (1)

(1) An exclusion constraint: no two rows for the same room may have overlapping time ranges. The database enforces it, so two simultaneous bookings cannot both succeed no matter how they interleave. This is the same idea as the conditional UPDATE used for seats and stock elsewhere — let the storage engine settle the race — and it is stronger here because "overlaps" is not something a simple equality check can express.

Without that constraint the naive version is broken: check whether the room is free, then insert. Two requests both check, both see a free room, both insert. It is the classic check-then-act race from 9.5.1, and it happens far more often than intuition suggests because everyone books at the same moments of the day.

Recurring meetings make room booking genuinely hard, and it needs a stated policy. A weekly meeting for a year is 52 claims. Some of them will clash with something already booked. Three defensible answers, and the design should support the choice:

All or nothing. Clean, and it will fail almost every time on a busy floor.

Book what is free, report the rest. Usually right, and it means the organiser gets a list of dates needing attention rather than a refusal.

Book a horizon and extend it. Claim the next three months, and a job extends the booking as time passes. This matches how rooms are actually used and it introduces a background job that must be idempotent and must be monitored, because a silently dead extension job means meetings with no room three months from now.

The room is released when the occurrence is cancelled, which is why the booking carries the originalStart — it is keyed to the occurrence, so cancelling one Tuesday frees one room slot and leaves the other 51 alone.

7. Reminders for a series that never ends

"Remind me ten minutes before" on an infinite weekly meeting cannot be scheduled as nine hundred jobs, and must not be.

The pattern is a rolling horizon. A job runs periodically, expands every series into a short future window — the next day or two — and schedules concrete reminders for the occurrences it finds. Nothing beyond the horizon has a scheduled reminder, and nothing needs one.

Three properties this needs, and each corresponds to a real failure:

It must be idempotent. The job will run twice, because schedulers deliver twice. A unique key of (seriesId, originalStart, userId) on scheduled reminders makes the second run insert nothing.

It must react to edits. A meeting moved after its reminder was scheduled needs the reminder moved too. The clean way is for the reminder to re-read the occurrence when it fires and cancel itself if the occurrence has moved or been cancelled — rather than trying to chase every edit with an update. This is exactly the auction's approach to its close job in 9.7.20, and for the same reason: re-reading at fire time is robust to every kind of change, and chasing edits is a race with the user.

It must be monitored. A reminder system that stops working is invisible, because nobody notices the notification that did not arrive. The metric that catches it is the number of occurrences inside the horizon with no scheduled reminder, which should be zero.

8. What the interviewer will push on

"How do you store a recurring event?" As a rule, expanded on read over a bounded window — never as generated rows. Then give the four reasons: infinite series cannot be written out, editing a series would rewrite hundreds of rows, the pattern itself is lost, and generated rows cannot be recomputed when a time zone rule changes. The tell is whether you mention the last one unprompted.

"Someone moves one meeting in the series." An override row keyed by the occurrence's original start, never by the new time. Explain the failure of the alternative: keyed by the new time, the rule still generates the original occurrence and nothing suppresses it, so the meeting appears twice. And a deleted occurrence is a cancelled override, because absence cannot be expressed by a rule that generates every Tuesday.

"This and all following events." Not an override — end the original rule the day before, create a new series from the split, and move the later overrides across. Writing an override per future occurrence is impossible for an infinite series and thousands of rows for a long one.

"A weekly 09:00 meeting when the clocks change." Store local time plus an IANA zone name, never an offset and never an instant. The user asked for nine o'clock, not for a fixed instant. Then the two awkward cases with stated rules: a time that does not exist on the spring change is pushed forward, and a time that happens twice on the autumn change takes the first. And a duration rather than an end time, so the meeting stays an hour long across the change.

"Find a slot for eight people." Expand, apply overrides, drop declined and free-marked events, convert to instants, merge intervals per person, merge across people, invert to find gaps, filter by duration and by each person's working hours in their own zone. Then say the useful part: this is not an algorithmic problem — 320 intervals sweep in microseconds — it is a modelling problem, and the value is in ranking slots rather than finding them.

"Two people book the same room at the same instant." A database exclusion constraint on room and time range, so overlapping bookings cannot both be written. Check-then-act loses this race, and it is not rare, because everyone books at the same few moments of the day. Then the recurrence follow-up: 52 weekly claims will partially clash, so state the policy — all or nothing, book what is free and report the rest, or a rolling horizon with an extension job.

The thing to volunteer that nobody asks for: expanding a slightly wider window than the one requested, then filtering by the occurrence's final time. An occurrence whose original start was Monday can be moved into Tuesday, so a generator restricted to the exact requested window will not produce it and the moved meeting vanishes from the view it was moved into. It is two lines, it is invisible in a diagram, and it is the bug every real calendar implementation has shipped at least once.

Recall

  • Store the rule, expand on read over a bounded window. Generated rows cannot express "forever", make series edits a mass rewrite, lose the pattern, and cannot be recomputed when a zone rule changes.
  • A single meeting is a series with a null rule, so there is one model rather than two.
  • Duration, not an end time — the meeting stays an hour long when the clocks change.
  • Local time plus an IANA zone name, never an offset, never an instant for a recurring event. Names know when offsets change; numbers do not.
  • Stated rules for the two awkward times: push forward the time that does not exist, take the first of the time that happens twice.
  • Overrides are keyed by original start. Key by the new time and the rule still generates the original, so the meeting shows twice.
  • A deleted occurrence is a cancelled override, because a rule cannot express absence.
  • "This and following" ends the rule and starts a new series, moving later overrides across. It is never a bulk override.
  • Expand a wider window than requested, filter by final time, or a meeting moved across a boundary disappears.
  • Attendance is per person, with four responses, an optional flag, and a busy-or-free flag that stops all-hands meetings blocking everyone.
  • Cancellation is a state, never a delete — attendees must see that it is off.
  • Slot finding: expand, drop declined and free, convert to instants, merge per person with max on the extend, merge across people, invert. The max prevents shrinking a busy block.
  • Scheduling is a modelling problem, not an algorithmic one. The value is in ranking slots.
  • Rooms are the contended resource: an exclusion constraint on room and time range. Check-then-act loses a race that happens constantly.
  • Reminders use a rolling horizon, are keyed idempotently, and re-read the occurrence when they fire rather than chasing edits.

Self-test: Why not generate rows? Why a zone name and not an offset? What is the override keyed by, and what breaks otherwise? Why is "this and following" a new series? Why widen the expansion window? Which single call prevents a double-booked room?

Quiz Bank

FoundationalModel a recurring event. Give the four reasons not to generate rows, and show the expansion.

The model is a rule plus a start, and the meetings are computed.

typescript
interface EventSeries {
  id: SeriesId;
  organiserId: UserId;
  title: string;
  durationMinutes: number;
  startLocal: LocalDateTime;        // "2026-03-03T10:00" — no zone in the value
  timeZone: IanaZone;               // "Europe/London" — a name, not an offset
  recurrence: RecurrenceRule | null;
}

interface RecurrenceRule {
  frequency: "daily" | "weekly" | "monthly" | "yearly";
  interval: number;
  byWeekday?: Weekday[];
  byMonthDay?: number[];
  ends: { kind: "never" } | { kind: "after"; count: number }
      | { kind: "onDate"; date: LocalDate };
}

The four reasons not to generate rows, each of which is a real failure rather than a preference:

"Forever" cannot be written out. ends: { kind: "never" } is what people actually choose for a standup. Generating rows means picking an arbitrary horizon and running a job to extend it, and that job is a silent single point of failure — when it stops, nobody notices until meetings simply are not there.

Editing the series becomes a mass rewrite. Moving a weekly meeting by thirty minutes touches every future row, in one transaction, while users are reading them. With a rule it is one field.

The pattern is destroyed. Once expanded, "every second Tuesday" is indistinguishable from a pile of unrelated meetings that happen to fall on Tuesdays. Nothing can display the rule, edit it, or export it to another calendar.

Zone rule changes cannot be repaired. When a government changes daylight saving dates, every generated row after the change is an hour wrong and there is no rule left to recompute from. With a stored rule, an operating system update fixes it for free. This is the reason candidates almost never give, and it is the one that has caused the most real incidents.

Expansion is a generator over the window that was asked for:

typescript
function* occurrences(s: EventSeries, from: LocalDate, to: LocalDate) {
  let cursor = s.startLocal;
  let produced = 0;
  while (cursor.date <= to) {
    if (matchesRule(cursor, s.recurrence) && cursor.date >= from)
      yield { seriesId: s.id, originalStart: cursor };
    produced++;
    if (s.recurrence?.ends.kind === "after" && produced >= s.recurrence.ends.count) return;
    cursor = advance(cursor, s.recurrence);
  }
}

An infinite series is not a problem because nobody asks for all of it. A screen shows a week or a month. The loop is bounded by that window, so the cost of rendering a calendar is proportional to what is on screen rather than to how long the series runs.

Two details in there that carry weight.

originalStart is attached to every occurrence, and it is the occurrence's identity for the rest of the design — not a database identifier, since there is no row, but "the one the rule says starts at this local time". Section 2's overrides are keyed by it.

advance isolates the genuinely awkward arithmetic. "Every month on the 31st" has to decide what February means, and the usual answer is to skip months without that day rather than clamp to the 28th — a monthly meeting quietly moving to a different day of the month is worse than one that does not happen. Putting that decision in one function keeps it out of everything else and makes it a policy that can be stated.

And durationMinutes rather than an end time, because a meeting that starts at 10:00 and lasts an hour must still last an hour on the day the clocks move. An end time of 11:00 becomes a two-hour or a zero-length meeting on exactly one day of the year, which is the sort of bug that is reported once and never reproduced.

AppliedA weekly Tuesday meeting. The user moves 14 March to 11:00, deletes 24 March, and changes the time of everything from 7 April. Implement all three.

All three are different mechanisms, and treating them as one is the mistake.

Move one occurrence — an override keyed by original start.

typescript
interface OccurrenceOverride {
  seriesId: SeriesId;
  originalStart: LocalDateTime;      // 2026-03-14T10:00 — the key
  change:
    | { kind: "cancelled" }
    | { kind: "moved"; newStart: LocalDateTime; newDuration?: number }
    | { kind: "edited"; title?: string; location?: string };
}

The key is the original start, and this is the single most important decision here. Suppose the override were keyed by the new time, 11:00. Expanding the rule still produces the 10:00 occurrence on 14 March, and there is nothing keyed at 10:00 to suppress it — so the user sees the meeting twice, once where it was and once where they moved it. The original start is the only value that the rule and the override can both name, which is exactly why it has to be the identity.

Delete one occurrence — a cancelled override, not a missing row. There is no row to delete: the occurrence never existed as data, it was generated. Absence cannot be expressed by a rule that generates every Tuesday, so the cancellation must be a positive fact recorded somewhere.

Change everything from a date — a new series, not a bulk override.

typescript
// 1. end the original rule the day before the split
original.recurrence.ends = { kind: "onDate", date: "2026-03-31" };

// 2. create a new series from the split, with the new details
const continuation = { ...original, id: newId(), startLocal: "2026-04-07T14:00" };

// 3. move overrides dated on or after the split to the new series
for (const ov of overrides.filter(o => o.originalStart >= splitDate))
  ov.seriesId = continuation.id;

Writing an override for every future occurrence is impossible for an infinite series and thousands of rows for a long one, so the split is the only workable approach. Step three is the one people forget: a meeting that was already moved to a different time must stay moved after the split, and that only happens if its override travels with it.

Expansion then combines the rule and the overrides:

typescript
function expand(s: EventSeries, ovs: OccurrenceOverride[], from, to) {
  const byKey = new Map(ovs.map(o => [key(o.originalStart), o]));
  const out: Occurrence[] = [];
  for (const occ of occurrences(s, from.minus(SLACK), to.plus(SLACK))) {
    const ov = byKey.get(key(occ.originalStart));
    if (ov?.change.kind === "cancelled") continue;
    out.push(ov ? applyOverride(occ, ov) : occ);
  }
  return out.filter(o => inWindow(o, from, to));
}

The SLACK is the detail worth pointing at. An occurrence whose original start was Monday 30 March can have been moved to Wednesday 1 April. If the generator is restricted to exactly the requested April window, it never produces that Monday occurrence, so the override has nothing to attach to, and the meeting vanishes from the month it was moved into. Widening the generated window by a few days and filtering at the end by the occurrence's final time fixes it in two lines. This is invisible in any diagram and it is a bug every real calendar has shipped at least once.

One product rule that belongs with the code. The three-button dialog — this event, this and following, all events — is only honest if all three are implemented. A calendar that quietly applies "all events" when the user chose "this event" destroys work, and users do not forgive it, because by the time they notice, the original times are gone.

InterviewA daily 09:00 standup in London, with attendees in New York and Bangalore. Walk through what is stored, what each person sees, and what happens on the day the clocks change.

What is stored is a local time and a zone name, and nothing else about time.

typescript
startLocal: "2026-03-03T09:00",   // no zone inside the value
timeZone:   "Europe/London",       // a name, not an offset
durationMinutes: 15,

Why not an instant. Converting to UTC at creation time fixes the meeting to one absolute moment in the cycle. The London clocks move forward in late March, and every meeting after that arrives an hour late for the London attendees, who asked for "nine o'clock" and got "09:00 UTC". The user's request was about the local clock, so the local clock is what must be stored.

Why not an offset. +00:00 is a number that knows nothing. Europe/London is a name that resolves, through the operating system's zone database, to +0 in winter and +1 in summer — and, more importantly, to whatever the rule becomes if a government changes it. Countries have changed their switch dates, abolished daylight saving, and moved zones entirely, usually with a few months' notice. A calendar storing names is corrected by a routine system update; a calendar storing offsets is silently wrong from the day the rule changes and has nothing to recompute from.

Why a duration rather than an end time. A 15-minute standup must still be 15 minutes on the day the clocks move. An end time of 09:15 stored as a separate local time works fine on ordinary days and produces a meeting of the wrong length on exactly one day a year — the sort of bug that gets reported once and never reproduced.

What each person sees. Rendering converts the stored local time and zone into an absolute instant, then into each viewer's own zone:

  • London: 09:00
  • New York: 04:00 (five hours behind, most of the year)
  • Bangalore: 14:30 (five and a half hours ahead of London in winter)

Nothing about the stored data differs per person. Conversion happens at the edge, at render time, which is what stops zone handling leaking into the rest of the system.

Now the day the clocks change, and this is the whole point of the question. In late March, London moves from +0 to +1. The stored value does not change at all — the meeting is still "09:00 Europe/London". What changes is everyone else's view:

  • London: still 09:00, exactly as intended.
  • New York moves the following week, so for that one week the gap is four hours instead of five, and the meeting shows at 05:00 rather than 04:00.
  • Bangalore does not observe daylight saving at all, so the meeting moves from 14:30 to 13:30 for them.

Two of the three attendees see the meeting move, and that is correct rather than a bug. The meeting is anchored to London. Every calendar in the world behaves this way, and the reason to say it out loud is that a user in Bangalore will report it as a bug, and the answer is a product explanation rather than a code change.

The two awkward local times need written-down rules.

A time that does not exist. On the spring change, 01:30 never happens in London. A meeting scheduled then is pushed forward by the size of the jump, to 02:30. Any consistent rule works; having none means either a crash or a meeting that silently does not appear.

A time that happens twice. On the autumn change, 01:30 happens twice. The standard choice is the first. Again the requirement is a decision, not a particular one.

And the question with no universal answer, which is worth raising unprompted. The organiser moves from London to Tokyo. Does the series follow them, or stay anchored to London for the six other attendees? Most calendars keep the series in its original zone — a meeting has a zone, a person does not — and that is defensible. But it must be a decision recorded in the design rather than whatever the code happens to do, because both behaviours are reasonable and users will assume the one you did not implement.

StaffFind a 45-minute slot next week for eight people across three continents, with a room. Show the algorithm and where the real difficulty is.

Step one, and this is where all the difficulty actually lives: turn eight calendars into eight lists of busy intervals.

For each person, expand every series over next week, apply their overrides, then filter out three things: occurrences they declined, occurrences marked free rather than busy, and events from calendars they have shared as free-busy only. Then convert every remaining occurrence to an absolute instant range, because comparing across three continents means comparing across zones and only instants are comparable.

Everything hard about scheduling is in that paragraph. Recurrence, overrides, cancellations, per-person responses, and zone conversion — each one is a place where the answer can be quietly wrong in a way nobody detects until someone misses a meeting.

Step two: merge each person's overlapping intervals.

typescript
function merge(intervals: Interval[]): Interval[] {
  const sorted = [...intervals].sort((a, b) => cmp(a.start, b.start));
  const out: Interval[] = [];
  for (const iv of sorted) {
    const last = out[out.length - 1];
    if (last && iv.start <= last.end) last.end = max(last.end, iv.end);
    else out.push({ ...iv });
  }
  return out;
}

The max is not decoration. An interval can be entirely contained inside the previous one — a fifteen-minute call inside a two-hour workshop — and assigning iv.end directly would shrink the busy block from two hours to fifteen minutes, handing out a slot in the middle of the workshop. This is the one line in the algorithm that is easy to get wrong and produces a bug that looks like the calendar being haunted.

Step three: merge all the required attendees' intervals together and invert. The gaps between merged busy blocks are the times when everyone is free. Filter those gaps to ones at least 45 minutes long.

Step four: intersect with working hours, per person, in their own zone. This is what makes three continents hard, and it is data rather than logic: 09:00–17:00 in London, in New York and in Bangalore leaves a very narrow overlap, and for some combinations there is no overlap at all. A scheduler that returns nothing must say why — "no time exists where all three regions are working" is actionable, and an empty list is not.

Optional attendees do not block. They are advisory, used for ranking rather than filtering. Forgetting this makes the search return nothing for any meeting with a large invite list, which is exactly the meeting people need help scheduling.

Now the honest framing. Eight people, five days, forty meetings each is 320 intervals. Sorted and swept once, that is microseconds. Slot finding is not an algorithmic problem — it is a modelling problem, and every genuine difficulty is in step one. Saying this plainly is a better answer than optimising a sweep that was never slow.

Where the product value is: ranking, not finding. Any calendar can list free slots. A useful one prefers the earliest workable time, avoids slots that leave someone with a useless twenty-minute gap either side, avoids lunch in each person's own zone, prefers times when the optional attendees are also free, and avoids the slot immediately after a long meeting. That preference function is the feature; the interval sweep is plumbing.

Then the room, which is the only genuinely contended resource here. Two people conflicting is annoying; a double-booked room is two groups standing in a doorway.

sql
INSERT INTO room_bookings (room_id, during, series_id, original_start)
VALUES (:roomId, tstzrange(:start, :end), :seriesId, :originalStart);
-- EXCLUDE USING gist (room_id WITH =, during WITH &&)

The exclusion constraint means the database refuses two overlapping rows for the same room, so two simultaneous bookings cannot both succeed regardless of how they interleave. The naive alternative — check whether the room is free, then insert — is a check-then-act race, and it is not a theoretical one: everybody books at the same few moments of the day, right before the hour.

If this meeting recurs, room booking needs a stated policy, because 52 weekly claims will partly clash with what is already booked. Three defensible answers: all or nothing (clean, and it fails almost every time on a busy floor); book what is free and report the rest (usually right, since the organiser gets a list of dates to deal with instead of a refusal); or claim a rolling three-month horizon extended by a job (matches how rooms are really used, and introduces a background job that must be idempotent and monitored, since a dead extension job means meetings with no room three months out).

What I would monitor. The proportion of slot searches returning nothing, which reveals whether working-hours data or the optional-attendee rule is wrong; room double-booking attempts rejected by the constraint, which should be non-zero and proves the constraint is doing work; the age of the room-booking horizon, which silently rots when the extension job dies; and occurrences inside the reminder horizon with no scheduled reminder, which should be zero and is the only way to notice that notifications have stopped — because nobody reports the alert that did not arrive.

Flashcards

FlashRule, not rows

Store the recurrence rule and expand on read. Rows cannot express "forever", make a series edit a mass rewrite, destroy the pattern, and cannot be recomputed when a government changes a daylight saving rule.

FlashLocal time plus a zone name

Never an offset, never an instant for a recurring event. Europe/London knows when the offset changes; +00:00 does not. Store a duration rather than an end time, so the meeting keeps its length across the change.

FlashOverrides are keyed by original start

Key by the new time and the rule still generates the original occurrence with nothing to suppress it — the meeting shows twice. A deleted occurrence is a cancelled override, because a rule cannot express absence.

FlashThis and following

End the original rule the day before the split, create a new series, and move the later overrides across. Never a bulk override — that is impossible for an infinite series and thousands of rows for a long one.

FlashWiden, then filter

Expand a few days beyond the requested window and filter by each occurrence's final time. An occurrence moved from Monday into Tuesday is otherwise never generated, so it disappears from the month it was moved into.

FlashRooms, not people, are contended

A database exclusion constraint on room and overlapping time range. Check-then-act loses this race constantly, because everyone books at the same few minutes before the hour.

Next: 9.9.1 — from designing the machine to running one in production.