skip to content

Designing a Calendar System

Updated: at 12:00 AM

We’re building a calendar system that supports both single and recurring events (daily, weekly, monthly). Participants can respond to events with accepted, rejected, or tentative.

For recurring events, any single occurrence can be overridden — you can modify it, cancel it, or change a participant’s response just for that instance. The system must also support querying events within a given time range.

To keep things simple, assume a single-process system with no concurrency and no timezone handling.

Now let’s look at some initial design ideas and why they may not be good choices.

Storing Every Occurrence as a Separate Event

The issues with this approach is

Core Idea

Model recurrence as a base event plus a recurrence rule. Individual occurrences are not stored. They are generated lazily when you query for a time range.

If a specific occurrence is modified or cancelled, that change is stored separately as an override. Overrides are sparse — you only store differences from the base series.

This creates three clear layers:

State Model

class Frequency(Enum):
	DAILY = "DAILY"
	WEEKLY = "WEEKLY"
	MONTHLY = "MONTHLY"

class ResponseStatus(Enum):
	PENDING = "PENDING"
	ACCEPTED = "ACCEPTED"
	REJECTED = "REJECTED"
	TENTATIVE = "TENTATIVE"

class User:
	user_id: str
	name: str

class RecurrenceRule:
	frequency: Frequency
	interval: int
	until: Optional[datetime]

class EventParticipant:
	user: User
	status: ResponseStatus = ResponseStatus.PENDING

class EventOverride:
	occurrence_start: datetime
	cancelled: bool
	modified_start: Optional[datetime]
	modified_end: Optional[datetime]
	participant_overrides: Dict[str, ResponseStatus]

class Event:
	event_id: str
	title: str
	start: datetime
	end: datetime
	participants: Dict[str, ResponseStatus]

User

A pure identity object. It represents a participant and contains no scheduling logic.

RecurrenceRule

Defines how a series progresses over time.

EventParticipant

Encapsulates default participant state for the series. Series-level default. Per-occurrence overrides live elsewhere.

EventOverride

Sparse mutation object. Override does not duplicate entire event. It stores only changes. Changes contain cancellation, time modification, participant status modification

Event (Materialized Instance)

Represents a resolved concrete event. This is not persisted. It is generated dynamically based on query

EventSeries

This class owns, Base event definition, Recurrence rule, Participants, Overrides, Occurrence generation logic

Occurrence Generation

Algorithm:

  1. If no recurrence → treat as single event
  2. Else:
    • Start from base start
    • Step forward
    • Stop at range_end
    • Respect recurrence until
    • Include only overlapping occurrences

Time complexity:

Query Resolution Flow

This is the most important part.

get_events_in_range() performs:

Step 1: Generate Base Occurrences

Using recurrence rule.

Step 2: For Each Occurrence

Apply override layer:

  1. If cancelled → skip
  2. If modified time → replace start/end
  3. Resolve participant states:

Resolution order:

Override status
→ else series default status

This creates final participant map.

Step 3: Materialize Event

Create Event object with resolved state.

Append to results.

Invariants

  1. Overrides are keyed by original occurrence start
  2. Series start time defines recurrence anchor
  3. Participants exist at series level only
  4. Event instances are never stored, only derived
  5. Cancellation is override-level, not deletion

Minimal Class Diagram

classDiagram

class User {
    user_id: str
    name: str
}

class RecurrenceRule {
    frequency: Frequency
    interval: int
    until: datetime | None
}

class EventParticipant {
    user: User
    status: ResponseStatus
}

class EventOverride {
    occurrence_start: datetime
    cancelled: bool
    modified_start: datetime | None
    modified_end: datetime | None
    participant_overrides: dict[str, ResponseStatus]
}

class Event {
    event_id: str
    title: str
    start: datetime
    end: datetime
    participants: dict[str, ResponseStatus]
}

class EventSeries {
    event_id: str
    title: str
    start: datetime
    end: datetime
    organizer: User
    recurrence_rule: RecurrenceRule | None
    participants: dict[str, EventParticipant]
    overrides: dict[datetime, EventOverride]
    generate_occurrences(range_start: datetime, range_end: datetime): list[datetime]
}

User --> EventParticipant
EventSeries --> RecurrenceRule
EventSeries --> EventParticipant
EventSeries --> EventOverride
EventSeries --> Event : materializes

Implementation

from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from enum import Enum
import uuid
import calendar


class Frequency(Enum):
    DAILY = "DAILY"
    WEEKLY = "WEEKLY"
    MONTHLY = "MONTHLY"


class ResponseStatus(Enum):
    PENDING = "PENDING"
    ACCEPTED = "ACCEPTED"
    REJECTED = "REJECTED"
    TENTATIVE = "TENTATIVE"


class User:
    def __init__(self, user_id: str, name: str):
        self.user_id = user_id
        self.name = name


class RecurrenceRule:
    def __init__(self, frequency: Frequency, interval: int = 1, until: datetime | None = None):
        self.frequency = frequency
        self.interval = interval
        self.until = until


class EventParticipant:
    def __init__(self, user: User):
        self.user = user
        self.status = ResponseStatus.PENDING


class EventOverride:
    def __init__(self, occurrence_start: datetime):
        self.occurrence_start = occurrence_start
        self.cancelled = False
        self.modified_start: datetime | None = None
        self.modified_end: datetime | None = None
        self.participant_overrides: dict[str, ResponseStatus] = {}


class Event:
    def __init__(self, event_id, title, start, end, participants):
        self.event_id = event_id
        self.title = title
        self.start = start
        self.end = end
        self.participants: dict[str, ResponseStatus] = participants

    def __repr__(self):
        return f"{self.title} | {self.start} - {self.end} | Participants - {[f"{k}: {v.value}" for k, v in self.participants.items()]}"


class EventSeries:
    def __init__(
        self,
        title: str,
        start: datetime,
        end: datetime,
        organizer: User,
        recurrence_rule: RecurrenceRule | None = None,
    ):
        self.event_id = str(uuid.uuid4())
        self.title = title
        self.start = start
        self.end = end
        self.organizer = organizer
        self.recurrence_rule = recurrence_rule

        self.participants: dict[str, EventParticipant] = {}
        self.overrides: dict[datetime, EventOverride] = {}

    def _next_occurrence(self, current: datetime) -> datetime:
        if not self.recurrence_rule:
            raise Exception("No recurrance rule to find next occurance")

        rule = self.recurrence_rule
        if rule.frequency == Frequency.DAILY:
            return current + timedelta(days=rule.interval)
        if rule.frequency == Frequency.WEEKLY:
            return current + timedelta(weeks=rule.interval)
        if rule.frequency == Frequency.MONTHLY:
            return current + relativedelta(months=rule.interval)

        raise Exception("Invalid recurrance rule")

    def _overlaps(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
        return start1 < end2 and end1 > start2

    def generate_occurrences(self, range_start: datetime, range_end: datetime):
        occurrences = []

        # Single event (takes overlapping intervals)
        if not self.recurrence_rule:
            if self._overlaps(start1=self.start, end1=self.end, start2=range_start, end2=range_end):
                occurrences.append(self.start)
            return occurrences

        # Recurring event (takes overlapping intervals)
        current, duration = self.start, self.end - self.start
        while current <= range_end:
            if self.recurrence_rule.until and current > self.recurrence_rule.until:
                break

            if self._overlaps(start1=current, end1=current + duration, start2=range_start, end2=range_end):
                occurrences.append(current)

            current = self._next_occurrence(current=current)

        return occurrences

class CalendarService:
    def __init__(self):
        self.events: dict[str, EventSeries] = {}

    def create_event(
        self,
        title: str,
        start: datetime,
        end: datetime,
        organizer: User,
        recurrence_rule: RecurrenceRule | None = None,
    ) -> str:
        event = EventSeries(
            title=title,
            start=start,
            end=end,
            organizer=organizer,
            recurrence_rule=recurrence_rule
        )
        self.events[event.event_id] = event
        return event.event_id

    def add_participant(self, event_id: str, user: User):
        event = self.events[event_id]
        event.participants[user.user_id] = EventParticipant(user=user)

    def respond(
        self,
        event_id: str,
        user_id: str,
        status: ResponseStatus,
        occurrence_start: datetime | None = None,
    ):
        event = self.events[event_id]

        if occurrence_start is None:
            event.participants[user_id].status = status
            return

        override = event.overrides.get(occurrence_start)
        if not override:
            override = EventOverride(occurrence_start=occurrence_start)
            event.overrides[occurrence_start] = override

        override.participant_overrides[user_id] = status

    def update_event(self, event_id: str, title=None, start=None, end=None):
        event = self.events[event_id]
        if title:
            event.title = title
        if start:
            event.start = start
        if end:
            event.end = end

    def update_occurrence(
        self,
        event_id: str,
        occurrence_start: datetime,
        new_start: datetime | None = None,
        new_end: datetime | None = None,
    ):
        event = self.events[event_id]

        override = event.overrides.get(occurrence_start)
        if not override:
            override = EventOverride(occurrence_start=occurrence_start)
            event.overrides[occurrence_start] = override

        override.modified_start = new_start
        override.modified_end = new_end

    def cancel_occurrence(self, event_id: str, occurrence_start: datetime):
        event = self.events[event_id]
        override = event.overrides.get(occurrence_start)
        if not override:
            override = EventOverride(occurrence_start=occurrence_start)
            event.overrides[occurrence_start] = override
        override.cancelled = True

    def get_events_in_range(self, range_start: datetime, range_end: datetime):
        results = []

        for event in self.events.values():
            occurrences = event.generate_occurrences(range_start=range_start, range_end=range_end)
            duration = event.end - event.start

            for occ_start in occurrences:
                occ_end = occ_start + duration

                override = event.overrides.get(occ_start)
                if override and override.cancelled:
                    continue

                if override:
                    if override.modified_start:
                        occ_start = override.modified_start
                    if override.modified_end:
                        occ_end = override.modified_end

                participants_state: dict[str, ResponseStatus] = {}

                for user_id, participant in event.participants.items():
                    status = participant.status
                    if override and user_id in override.participant_overrides:
                        status = override.participant_overrides[user_id]
                    participants_state[user_id] = status

                instance = Event(
                    event_id=event.event_id,
                    title=event.title,
                    start=occ_start,
                    end=occ_end,
                    participants=participants_state,
                )

                results.append(instance)

        return results