Calendar/docs/booking-event-architecture.md

931 lines
25 KiB
Markdown
Raw Normal View History

# 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.
```json
// 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.
```json
// 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.
```json
// 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.
```json
// 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.
```json
// 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.
```json
// 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**
```typescript
// 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)
```sql
-- 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)
```typescript
// 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:**
```typescript
// 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):**
```http
GET /api/events?start=2025-11-14&end=2025-11-21
```
```json
[
{
"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):**
```sql
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)
```typescript
// ✅ 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
```typescript
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)
```typescript
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)
```typescript
// 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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**