# 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; } ``` --- ## 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**