Calendar/docs/booking-event-architecture.md
Janus C. H. Knudsen a360fad265 Redesigns booking and resource architecture
Fundamentally refactors booking system to support:
- Split-resource bookings
- Service-level resource assignment
- Clear separation between booking and calendar events

Introduces new interfaces and type definitions to model complex booking scenarios, enabling more flexible and accurate resource management

Improves type safety by removing ambiguous type annotations
2025-11-14 23:05:57 +01:00

25 KiB

Booking vs CalendarEvent Architecture

Status: Active Architecture Document Last Updated: 2025-11-14 Purpose: Define the separation of concerns between Booking (business domain) and CalendarEvent (scheduling domain)


Core Principle: Separation of Concerns

The calendar system uses two distinct entities to model appointments:

  1. Booking = Customer + Services (business container - WHAT, WHO, HOW MUCH)
  2. CalendarEvent = Scheduling container (WHEN, WHICH RESOURCE)

This separation enables:

  • Flexible scheduling (one booking → multiple time slots or resources)
  • Queue management (bookings without scheduled time)
  • Time adjustments without business logic changes
  • Clear analytics (service duration vs actual time spent)
  • Split-resource bookings (one booking → multiple resources)

Entity Definitions

Booking (Domain Model)

Purpose: Represents a customer service booking ONLY

Contains:

  • customerId - Who receives the service (REQUIRED)
  • status - Lifecycle state: created, arrived, paid, noshow, cancelled
  • services[] - Services to be performed (REQUIRED, each has resourceId)
  • totalPrice - Business value (can differ from service prices)
  • tags, notes, createdAt - Metadata

Does NOT contain:

  • start / end - Timing is on CalendarEvent
  • type - Always customer, so redundant
  • resourceId - Resources assigned per service (IBookingService.resourceId)

Key Rules:

  • Booking is ALWAYS customer-related (customerId required)
  • Booking ALWAYS has services (services[] required)
  • Resources are assigned at SERVICE level (not booking level)
  • Booking can exist WITHOUT CalendarEvent (pending/queued state)
  • Booking CANNOT be deleted if CalendarEvents reference it
  • Vacation/break/meeting are NOT bookings - only CalendarEvents

CalendarEvent (Scheduling Model)

Purpose: Represents a time slot in the calendar

Contains:

  • start - When does it happen (MASTER)
  • end - When does it end (MASTER)
  • allDay - Is it an all-day event
  • type - CalendarEventType: 'customer', 'vacation', 'break', 'meeting', 'blocked'
  • bookingId - Reference to business data (nullable - only 'customer' type has booking)
  • resourceId - Which resource owns this time slot
  • customerId - Denormalized for query performance
  • title, description - Display properties

Key Rules:

  • CalendarEvent is the MASTER for timing
  • CalendarEvent can exist WITHOUT Booking (vacation, breaks, reminders)
  • CalendarEvent CANNOT be deleted if it references a Booking
  • Only type='customer' events have associated bookings

Relationship: 1 Booking → N Events

Key Innovation: One booking can span MULTIPLE calendar events (split sessions OR split resources).

Example 1: Split Session (Same Resource, Multiple Days)

Scenario: Bundfarve takes 180 minutes but is split across 2 days.

// Booking (business contract)
{
  "id": "BOOK001",
  "customerId": "CUST456",
  "status": "created",
  "services": [
    {
      "serviceName": "Bundfarve komplet",
      "baseDuration": 180,
      "resourceId": "EMP001"  // Karina performs all
    }
  ],
  "totalPrice": 1500
}

// CalendarEvent 1 (first session)
{
  "id": "EVT001",
  "type": "customer",
  "title": "Anna - Bundfarve (Del 1)",
  "start": "2025-11-14T13:00:00",
  "end": "2025-11-14T15:00:00",     // 120 minutes
  "bookingId": "BOOK001",
  "resourceId": "EMP001",
  "customerId": "CUST456"
}

// CalendarEvent 2 (second session - next day)
{
  "id": "EVT002",
  "type": "customer",
  "title": "Anna - Bundfarve (Del 2)",
  "start": "2025-11-15T10:00:00",
  "end": "2025-11-15T11:00:00",     // 60 minutes
  "bookingId": "BOOK001",            // SAME booking!
  "resourceId": "EMP001",
  "customerId": "CUST456"
}

Analytics:

  • Service duration: 180 min (what customer paid for)
  • Actual time slots: 120 + 60 = 180 min (resource allocation)

Example 2: Split Resources (Master + Student)

Scenario: Hårvask (30 min) by student, then Bundfarve (90 min) by master.

// Booking (business contract)
{
  "id": "BOOK002",
  "customerId": "CUST456",
  "status": "created",
  "services": [
    {
      "serviceName": "Hårvask + bryn",
      "baseDuration": 30,
      "resourceId": "STUDENT001"  // Student performs this
    },
    {
      "serviceName": "Bundfarve",
      "baseDuration": 90,
      "resourceId": "EMP001"      // Master performs this
    }
  ],
  "totalPrice": 1160
}

// CalendarEvent 1 (student's time slot)
{
  "id": "EVT003",
  "type": "customer",
  "title": "Anna - Hårvask",
  "start": "2025-11-14T13:00:00",
  "end": "2025-11-14T13:30:00",
  "bookingId": "BOOK002",
  "resourceId": "STUDENT001",     // Student's calendar
  "customerId": "CUST456"
}

// CalendarEvent 2 (master's time slot)
{
  "id": "EVT004",
  "type": "customer",
  "title": "Anna - Bundfarve",
  "start": "2025-11-14T13:30:00",
  "end": "2025-11-14T15:00:00",
  "bookingId": "BOOK002",          // SAME booking!
  "resourceId": "EMP001",          // Master's calendar
  "customerId": "CUST456"
}

Key Points:

  • One booking spans TWO resources
  • Each resource gets their own CalendarEvent
  • Events can be at different times (student starts, then master)
  • Total time: student 30min + master 90min = 120min

Example 3: Split Resources (Two Masters, Equal Split)

Scenario: Bundfarve komplet (3 hours) split equally between Karina and Nanna.

// Booking (customer chose to split between two masters)
{
  "id": "BOOK003",
  "customerId": "CUST789",
  "status": "created",
  "services": [
    {
      "serviceName": "Bundfarve del 1",
      "baseDuration": 90,
      "resourceId": "EMP001"      // Karina
    },
    {
      "serviceName": "Bundfarve del 2",
      "baseDuration": 90,
      "resourceId": "EMP002"      // Nanna
    }
  ],
  "totalPrice": 2000
}

// CalendarEvent 1 (Karina's part)
{
  "id": "EVT005",
  "type": "customer",
  "start": "2025-11-14T10:00:00",
  "end": "2025-11-14T11:30:00",
  "bookingId": "BOOK003",
  "resourceId": "EMP001"          // Karina's calendar
}

// CalendarEvent 2 (Nanna's part)
{
  "id": "EVT006",
  "type": "customer",
  "start": "2025-11-14T11:30:00",
  "end": "2025-11-14T13:00:00",
  "bookingId": "BOOK003",
  "resourceId": "EMP002"          // Nanna's calendar
}

Key Points:

  • No "primary" resource - both are equal
  • Customer agreed to split during booking
  • Each master gets 90 minutes
  • Can be scheduled at different times if needed

Example 4: Queued Booking (No Events Yet)

Scenario: Customer wants an appointment but no time slot is available yet.

// Booking exists (in queue)
{
  "id": "BOOK004",
  "customerId": "CUST999",
  "status": "pending",
  "services": [
    {
      "serviceName": "Klipning",
      "baseDuration": 30,
      "resourceId": "EMP001"  // Pre-assigned to Karina
    }
  ]
}

// NO CalendarEvent yet!
// Query: SELECT * FROM CalendarEvent WHERE bookingId = 'BOOK004'
// Result: Empty

// When time is found, create event:
{
  "id": "EVT007",
  "type": "customer",
  "start": "2025-11-20T14:00:00",
  "end": "2025-11-20T14:30:00",
  "bookingId": "BOOK004",
  "resourceId": "EMP001"
}

// Update booking status:
UPDATE Booking SET status = 'scheduled' WHERE id = 'BOOK004'

Example 5: Vacation (NO Booking)

Scenario: Karina is on vacation - NOT a booking.

// NO Booking exists!

// CalendarEvent only
{
  "id": "EVT008",
  "type": "vacation",              // NOT 'customer'
  "title": "Karina - Juleferie",
  "start": "2025-12-23T00:00:00",
  "end": "2025-12-30T23:59:59",
  "allDay": true,
  "bookingId": null,               // NO booking reference
  "resourceId": "EMP001",          // Karina
  "customerId": null
}

Key Points:

  • Vacation/break/meeting are CalendarEvents ONLY
  • They do NOT have associated bookings
  • type determines whether booking exists:
    • type = 'customer' → has booking
    • type = 'vacation'/'break'/'meeting' → no booking

Example 6: Simple Reminder (NO Booking, NO Resource)

Scenario: Personal task not tied to any business transaction.

// CalendarEvent (standalone)
{
  "id": "EVT009",
  "type": "meeting",               // Or custom type
  "title": "Husk at ringe til leverandør",
  "start": "2025-11-15T10:00:00",
  "end": "2025-11-15T10:15:00",
  "bookingId": null,               // NO booking
  "resourceId": null,              // NO resource
  "customerId": null
}

Duration Calculation

Service Bookings

Duration = Sum of service durations

// Booking
{
  "services": [
    { "baseDuration": 90, "resourceId": "EMP001" },
    { "baseDuration": 30, "resourceId": "STUDENT001" }
  ]
}

// Initial event creation:
const totalServiceDuration = services.reduce((sum, s) => sum + s.baseDuration, 0);
// totalServiceDuration = 120 minutes

// Create events (one per resource or split session)
events = services.map(service => ({
  start: calculateStartTime(service),
  end: start + service.baseDuration,
  resourceId: service.resourceId
}));

User Adjustment: User can drag/resize event.end in calendar (e.g., from 15:00 to 15:30)

  • Services still show 120min (what was booked)
  • Event shows 150min (actual time spent)
  • This is CORRECT behavior (tracks overrun)

Database Schema

Backend (SQL - Normalized)

-- Booking table (NO timing, NO resourceId)
CREATE TABLE Booking (
    Id VARCHAR(50) PRIMARY KEY,
    CustomerId VARCHAR(50) NOT NULL,        -- FK to Customer (REQUIRED)
    Status VARCHAR(20) NOT NULL,
    TotalPrice DECIMAL(10,2) NULL,
    CreatedAt DATETIME NOT NULL,
    Tags NVARCHAR(500) NULL,
    Notes NVARCHAR(MAX) NULL,

    FOREIGN KEY (CustomerId) REFERENCES Customer(Id)
);

-- CalendarEvent table (HAS timing)
CREATE TABLE CalendarEvent (
    Id VARCHAR(50) PRIMARY KEY,
    Title NVARCHAR(200) NOT NULL,
    Description NVARCHAR(MAX) NULL,
    Start DATETIME NOT NULL,
    End DATETIME NOT NULL,
    AllDay BIT NOT NULL,
    Type VARCHAR(50) NOT NULL,              -- 'customer', 'vacation', 'break', etc.

    -- References (BookingId is nullable - only 'customer' type has booking)
    BookingId VARCHAR(50) NULL,

    -- Denormalized (for query performance)
    ResourceId VARCHAR(50) NULL,            -- Which resource owns this slot
    CustomerId VARCHAR(50) NULL,            -- Copied from Booking

    FOREIGN KEY (BookingId) REFERENCES Booking(Id)
);

-- BookingService junction table (WITH ResourceId)
CREATE TABLE BookingService (
    BookingId VARCHAR(50) NOT NULL,
    ServiceId VARCHAR(50) NOT NULL,
    ResourceId VARCHAR(50) NOT NULL,        -- NEW: Resource performing THIS service
    CustomPrice DECIMAL(10,2) NULL,
    SortOrder INT NULL,

    PRIMARY KEY (BookingId, ServiceId),
    FOREIGN KEY (BookingId) REFERENCES Booking(Id),
    FOREIGN KEY (ServiceId) REFERENCES Service(Id),
    FOREIGN KEY (ResourceId) REFERENCES Resource(Id)
);

Key Changes from Old Schema:

  • Booking table NO LONGER has ResourceId
  • Booking table NO LONGER has Type
  • BookingService table NOW has ResourceId (NEW!)
  • CalendarEvent.Type distinguishes customer vs vacation/break

Why ResourceId/CustomerId on CalendarEvent?

  • Performance: Filter events by resource WITHOUT joining Booking/BookingService
  • IndexedDB: Frontend needs these for fast queries (no JOIN support)

Frontend (IndexedDB - Denormalized)

// IndexedDB stores
const db = await openDB('CalendarDB', 1, {
  upgrade(db) {
    // Events store (MASTER for queries)
    const eventStore = db.createObjectStore('events', { keyPath: 'id' });
    eventStore.createIndex('by_start', 'start');
    eventStore.createIndex('by_type', 'type');
    eventStore.createIndex('by_resourceId', 'resourceId');   // Denormalized!
    eventStore.createIndex('by_customerId', 'customerId');   // Denormalized!
    eventStore.createIndex('by_bookingId', 'bookingId');

    // Bookings store (REFERENCE data, loaded on-demand)
    const bookingStore = db.createObjectStore('bookings', { keyPath: 'id' });
    bookingStore.createIndex('by_customerId', 'customerId');
    bookingStore.createIndex('by_status', 'status');
  }
});

Query Pattern:

// Get events for resource (NO JOIN needed)
async getEventsForResource(resourceId: string, start: Date, end: Date) {
  const events = await db.getAllFromIndex('events', 'by_resourceId', resourceId);
  return events.filter(e => e.start >= start && e.start <= end);
}

// Get customer bookings (via events)
async getCustomerBookingsInPeriod(customerId: string, start: Date, end: Date) {
  // 1. Get customer events
  const events = await db.events
    .where('customerId').equals(customerId)
    .filter(e => e.start >= start && e.start <= end && e.type === 'customer')
    .toArray();

  // 2. Get unique booking IDs
  const bookingIds = [...new Set(events.map(e => e.bookingId).filter(Boolean))];

  // 3. Load bookings
  const bookings = await Promise.all(
    bookingIds.map(id => db.get('bookings', id))
  );

  return bookings;
}

// Get booking with all resources involved
async getBookingResources(bookingId: string) {
  const booking = await db.get('bookings', bookingId);

  // Extract unique resources from services
  const resourceIds = [...new Set(booking.services.map(s => s.resourceId))];

  return resourceIds;
}

API Data Flow

Backend Normalization → Frontend Denormalization

Backend API Response (Denormalized via JOIN):

GET /api/events?start=2025-11-14&end=2025-11-21
[
  {
    "id": "EVT001",
    "type": "customer",
    "title": "Anna - Bundfarve",
    "start": "2025-11-14T13:00:00",
    "end": "2025-11-14T15:00:00",
    "allDay": false,
    "bookingId": "BOOK001",
    "resourceId": "EMP001",      // ← Backend determines from event context
    "customerId": "CUST456"      // ← Backend JOINed from Booking
  },
  {
    "id": "EVT008",
    "type": "vacation",
    "title": "Karina - Juleferie",
    "start": "2025-12-23T00:00:00",
    "end": "2025-12-30T23:59:59",
    "allDay": true,
    "bookingId": null,           // ← No booking for vacation
    "resourceId": "EMP001",
    "customerId": null
  }
]

Backend SQL (with JOIN):

SELECT
  e.Id,
  e.Type,
  e.Title,
  e.Start,
  e.End,
  e.AllDay,
  e.BookingId,
  e.ResourceId,              -- Already on CalendarEvent (denormalized)
  b.CustomerId               -- Joined from Booking (if exists)
FROM CalendarEvent e
LEFT JOIN Booking b ON e.BookingId = b.Id
WHERE e.Start >= @start AND e.Start <= @end

Business Rules

Rule 1: Booking Requires Customer (Always)

// ✅ VALID - Booking always has customer + services
{
  "customerId": "CUST456",
  "services": [
    { "resourceId": "EMP001", "serviceName": "Klipning" }
  ]
}

// ❌ INVALID - Booking without customer
{
  "customerId": null,          // ERROR: Booking requires customer
  "services": [...]
}

// ❌ INVALID - Booking without services
{
  "customerId": "CUST456",
  "services": []               // ERROR: Booking requires services
}

Rule 2: Event Cannot Be Deleted If It Has Booking

async deleteEvent(eventId: string) {
  const event = await db.get('events', eventId);

  if (event.bookingId) {
    throw new Error("Cannot delete event with booking. Cancel booking first.");
  }

  await db.delete('events', eventId);  // OK for vacation/reminders
}

Rule 3: Booking Can Be Cancelled (Cascades to Events)

async cancelBooking(bookingId: string) {
  // 1. Update booking status
  await db.bookings.update(bookingId, { status: 'cancelled' });

  // 2. Find ALL related events (may be multiple resources/sessions)
  const events = await db.events.where('bookingId').equals(bookingId).toArray();

  // 3. Delete OR mark cancelled
  for (const event of events) {
    await db.events.delete(event.id);  // Remove from calendar
  }
}

Rule 4: Moving Event Updates Only Event (Not Booking)

// User drags event from 13:00 to 14:00
async moveEvent(eventId: string, newStart: Date, newEnd: Date) {
  // Update ONLY the event
  await db.events.update(eventId, {
    start: newStart,
    end: newEnd
  });

  // Booking services/price remain unchanged
  // (This is correct - tracks time adjustments vs booked services)
}

Rule 5: Service Resource Can Be Changed (Elev Syg)

// Elev is sick - reassign to another student
async reassignServiceResource(bookingId: string, serviceId: string, newResourceId: string) {
  // 1. Update booking service
  const booking = await db.get('bookings', bookingId);
  const service = booking.services.find(s => s.serviceId === serviceId);
  service.resourceId = newResourceId;
  await db.put('bookings', booking);

  // 2. Find and update related event
  const events = await db.events.where('bookingId').equals(bookingId).toArray();
  const eventToUpdate = events.find(e => e.resourceId === oldResourceId);

  if (eventToUpdate) {
    eventToUpdate.resourceId = newResourceId;
    await db.put('events', eventToUpdate);
  }
}

Analytics & Reporting

Service Duration vs Actual Duration

// Get booking with actual time spent
async getBookingAnalytics(bookingId: string) {
  const booking = await db.get('bookings', bookingId);
  const events = await db.events.where('bookingId').equals(bookingId).toArray();

  // Booked duration (from services)
  const bookedDuration = booking.services
    .reduce((sum, s) => sum + s.baseDuration, 0);

  // Actual duration (from events)
  const actualDuration = events
    .reduce((sum, e) => sum + (e.end - e.start) / 60000, 0);

  return {
    bookedMinutes: bookedDuration,     // 120 min (what customer paid for)
    actualMinutes: actualDuration,     // 150 min (real time spent)
    overrun: actualDuration - bookedDuration  // +30 min
  };
}

Resource Utilization

// How much time did Karina work in November?
async getResourceUtilization(resourceId: string, month: Date) {
  const start = startOfMonth(month);
  const end = endOfMonth(month);

  // Query events (NOT bookings - because events have timing)
  const events = await db.events
    .where('resourceId').equals(resourceId)
    .filter(e => e.start >= start && e.start <= end)
    .toArray();

  const totalMinutes = events
    .reduce((sum, e) => sum + (e.end - e.start) / 60000, 0);

  return {
    resourceId,
    month: format(month, 'yyyy-MM'),
    totalHours: totalMinutes / 60,
    eventCount: events.length,
    breakdown: {
      customer: events.filter(e => e.type === 'customer').length,
      vacation: events.filter(e => e.type === 'vacation').length,
      break: events.filter(e => e.type === 'break').length
    }
  };
}

Find Bookings Involving Resource

// Find all bookings where Karina is involved (even if not all services)
async getBookingsForResource(resourceId: string, start: Date, end: Date) {
  // 1. Get events for resource in period
  const events = await db.events
    .where('resourceId').equals(resourceId)
    .filter(e => e.start >= start && e.start <= end && e.type === 'customer')
    .toArray();

  // 2. Get unique booking IDs
  const bookingIds = [...new Set(events.map(e => e.bookingId).filter(Boolean))];

  // 3. Load bookings
  const bookings = await Promise.all(
    bookingIds.map(id => db.get('bookings', id))
  );

  return bookings;
}

TypeScript Interfaces (Reference)

// src/types/BookingTypes.ts
export interface IBooking {
  id: string;
  customerId: string;        // REQUIRED - always has customer
  status: BookingStatus;
  createdAt: Date;

  services: IBookingService[];  // REQUIRED - always has services
  totalPrice?: number;
  tags?: string[];
  notes?: string;

  // NO resourceId - assigned per service
  // NO type - always customer
  // NO start/end - timing on CalendarEvent
}

export interface IBookingService {
  serviceId: string;
  serviceName: string;
  baseDuration: number;
  basePrice: number;
  customPrice?: number;
  resourceId: string;        // Resource who performs THIS service
}

export type CalendarEventType =
  | 'customer'    // Customer appointment (HAS booking)
  | 'vacation'    // Vacation/time off (NO booking)
  | 'break'       // Lunch/break (NO booking)
  | 'meeting'     // Meeting (NO booking)
  | 'blocked';    // Blocked time (NO booking)

export type BookingStatus =
  | 'created'     // AftaleOprettet
  | 'arrived'     // Ankommet
  | 'paid'        // Betalt
  | 'noshow'      // Udeblevet
  | 'cancelled';  // Aflyst

// src/types/CalendarTypes.ts
export interface ICalendarEvent {
  id: string;
  title: string;
  description?: string;

  start: Date;               // MASTER for timing
  end: Date;                 // MASTER for timing
  allDay: boolean;

  type: CalendarEventType;   // Determines if booking exists

  // References (denormalized)
  bookingId?: string;        // Only if type = 'customer'
  resourceId?: string;       // Which resource owns this slot
  customerId?: string;       // Denormalized from Booking

  syncStatus: SyncStatus;
  recurringId?: string;
  metadata?: Record<string, any>;
}

Common Pitfalls

Pitfall 1: Trying to Query Bookings by Time

// WRONG - Bookings don't have start/end
const bookings = await db.bookings
  .where('start').between(startDate, endDate)  // ERROR: No 'start' field!
  .toArray();

// CORRECT - Query events, then load bookings
const events = await db.events
  .where('start').between(startDate, endDate)
  .filter(e => e.type === 'customer')  // Only customer events have bookings
  .toArray();

const bookingIds = events
  .filter(e => e.bookingId)
  .map(e => e.bookingId);

const bookings = await Promise.all(
  bookingIds.map(id => db.get('bookings', id))
);

Pitfall 2: Assuming 1:1 Relationship

// WRONG - Assumes one event per booking
const booking = await db.get('bookings', bookingId);
const event = await db.events
  .where('bookingId').equals(bookingId)
  .first();  // Only gets first event!

// CORRECT - Get ALL events for booking (split sessions/resources)
const events = await db.events
  .where('bookingId').equals(bookingId)
  .toArray();  // May return multiple events

Pitfall 3: Looking for resourceId on Booking

// WRONG - Booking doesn't have resourceId
const booking = await db.get('bookings', bookingId);
console.log(booking.resourceId);  // ERROR: undefined!

// CORRECT - Get resources from services
const resourceIds = [...new Set(booking.services.map(s => s.resourceId))];
console.log(resourceIds);  // ["EMP001", "STUDENT001"]

Pitfall 4: Trying to Create Vacation as Booking

// WRONG - Vacation is NOT a booking
const vacationBooking = {
  customerId: null,
  type: 'vacation',
  services: []
};
await db.add('bookings', vacationBooking);  // ERROR: Invalid!

// CORRECT - Vacation is CalendarEvent only
const vacationEvent = {
  type: 'vacation',
  start: new Date('2025-12-23'),
  end: new Date('2025-12-30'),
  bookingId: null,          // NO booking
  resourceId: 'EMP001'
};
await db.add('events', vacationEvent);  // OK

Summary

Aspect Booking CalendarEvent
Purpose Customer + Services Time slot + Resource
Timing No start/end Has start/end
Resource Per service Per event (denormalized)
Type Always customer customer/vacation/break/meeting
Relationship 1 booking → N events N events → 0..1 booking
Can exist alone? Yes (queued) Yes (vacation/reminders)
Query by time? No Yes
Query by resource? No (via services) Yes (direct)
Master for... Services, price, customer Timing, scheduling, resource

Key Takeaways:

  • Use CalendarEvent for all scheduling queries (when, who)
  • Use Booking for business data (what, how much)
  • Only type='customer' events have bookings
  • Vacation/break/meeting are events without bookings
  • Resources are assigned at service level, not booking level
  • One booking can involve multiple resources (split services)

Decision Guide

If you're unsure whether something belongs in Booking or CalendarEvent, ask:

  1. "Is this about WHEN it happens?" → CalendarEvent
  2. "Is this about WHICH RESOURCE does it?" → CalendarEvent
  3. "Is this about WHAT/WHO/HOW MUCH?" → Booking
  4. "Can this change when the event is moved in calendar?"
    • If YES → CalendarEvent
    • If NO → Booking
  5. "Is this vacation/break/meeting?" → CalendarEvent ONLY (no booking)

Document End