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
This commit is contained in:
parent
f86ae09ec3
commit
a360fad265
7 changed files with 1557 additions and 2 deletions
930
docs/booking-event-architecture.md
Normal file
930
docs/booking-event-architecture.md
Normal file
|
|
@ -0,0 +1,930 @@
|
|||
# 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**
|
||||
Loading…
Add table
Add a link
Reference in a new issue