931 lines
25 KiB
Markdown
931 lines
25 KiB
Markdown
|
|
# 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**
|