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
- Long or infinite recurrences create a huge number of rows.
- Changing the series (time, title, etc.) means updating every occurrence.
- If the recurrence rule changes, existing rows no longer match the rule and are hard to fix.
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:
- EventSeries — the base event and its recurrence rule (if any). This defines the pattern.
- EventOverride — changes applied to a specific occurrence (modify, cancel, participant response changes).
- Event (materialized instance) — the final, resolved event produced at query time by combining the series with any matching overrides.
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.
frequencyspecifies the unit of repetition (daily, weekly, monthly).intervalspecifies how many units to step each time.untildefines the upper bound for the recurrence.
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:
- If no recurrence → treat as single event
- Else:
- Start from base start
- Step forward
- Stop at range_end
- Respect recurrence
until - Include only overlapping occurrences
Time complexity:
- O(k) where k = number of occurrences within range
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:
- If cancelled → skip
- If modified time → replace start/end
- 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
- Overrides are keyed by original occurrence start
- Series start time defines recurrence anchor
- Participants exist at series level only
- Event instances are never stored, only derived
- 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