diff --git a/coding-sessions/2025-11-14-booking-resource-architecture-redesign.md b/coding-sessions/2025-11-14-booking-resource-architecture-redesign.md new file mode 100644 index 0000000..d5bb413 --- /dev/null +++ b/coding-sessions/2025-11-14-booking-resource-architecture-redesign.md @@ -0,0 +1,522 @@ +# Booking & Resource Architecture Redesign + +**Date:** 2025-11-14 +**Duration:** ~2+ hours +**Initial Scope:** Create IResource interface and fix `Date | any` type +**Actual Scope:** Fundamental redesign of booking architecture with service-level resource assignment + +--- + +## Executive Summary + +What started as a simple task to create an `IResource` interface evolved into a comprehensive architectural redesign after critical developer questioning revealed fundamental flaws in the initial approach. Through 4 major iterations and multiple course corrections, we arrived at a clean architecture that properly models split-resource bookings (e.g., student + master stylist working on the same booking). + +**Key Achievement:** Avoided building 3 different incorrect architectures by thinking through real business scenarios before committing to code. + +--- + +## Original Request + +> "Can we create an IResource now? Then you can also fix `data: Date | any`" + +**Expected:** 10 minutes - create interface, update type, done. + +**Reality:** 2+ hours of architectural discussion, 4 major iterations, 930 lines of documentation. + +--- + +## Iteration History + +### Iteration 1: "Booking HAS resourceId (Primary Resource)" ❌ + +**My Initial Assumption:** +```typescript +export interface IBooking { + id: string; + resourceId: string; // Primary resource + customerId?: string; + type: BookingType; + services?: IBookingService[]; +} +``` + +**Rationale:** Bookings are always resource-bound (someone performs the service), so booking should have a resourceId. + +**Developer Challenge:** +> "A booking with 2 services can be split between multiple resources. For example, a large hair coloring - the student handles hair wash and eyebrows, the master stylist does the actual coloring." + +**Problem Identified:** +- Booking.resourceId assumes ONE primary resource +- But in reality: hårvask (student) + bundfarve (master) = TWO resources, neither "primary" +- Model doesn't support split-resource bookings + +**Result:** ❌ Discarded after 15 minutes of discussion + +--- + +### Iteration 2: "Booking HAS Primary + Services Have Resources" ❌ + +**My Second Attempt:** +```typescript +export interface IBooking { + id: string; + resourceId: string; // "Primary" responsible person + customerId?: string; + services?: IBookingService[]; +} + +export interface IBookingService { + serviceId: string; + resourceId: string; // Who performs THIS service +} +``` + +**Rationale:** Keep primary resource for "who is responsible" while allowing service-level assignment. + +**Developer Challenge:** +> "Why should there be a primary? Use case: Customer chooses service, UI shows it can be split between Karina and Nanna, customer says yes. Here there's no primary, both are master stylists." + +**Problem Identified:** +- "Primary resource" is an artificial concept +- Equal splits (Karina + Nanna) don't have a "responsible person" +- Creates confusion: "Who is primary if they're equal?" + +**Result:** ❌ Discarded after thorough use case review + +--- + +### Iteration 3: "Booking WITHOUT resourceId, Only Services Have Resources" ✅ + +**My Third Attempt:** +```typescript +export interface IBooking { + id: string; + customerId: string; // REQUIRED + services: IBookingService[]; // REQUIRED + // NO resourceId at all! +} + +export interface IBookingService { + serviceId: string; + resourceId: string; // Resource performs THIS service +} +``` + +**My Concern:** +> "But what about vacation/break? Those are resource-bound but have no services!" + +**Developer Response:** +> "Vacation is not a booking, it's just a CalendarEvent with resourceId. Bookings are only for customers. We always have a booking for customer services." + +**Breakthrough Realization:** +- **Booking = Customer + Services** (business domain) +- **CalendarEvent = Scheduling + Resource** (time domain) +- Vacation/break/meeting are CalendarEvents WITHOUT bookings + +**Architecture Clarified:** +```typescript +// Booking = ONLY customer services +export interface IBooking { + customerId: string; // REQUIRED - always customer + services: IBookingService[]; // REQUIRED - always has services + // NO resourceId + // NO type (always customer) +} + +// CalendarEvent = ALL scheduling +export interface ICalendarEvent { + type: CalendarEventType; // 'customer' | 'vacation' | 'break' | ... + bookingId?: string; // Only if type = 'customer' + resourceId?: string; // Which resource owns this slot +} +``` + +**Result:** ✅ **ACCEPTED** - Clean separation of concerns + +--- + +### Iteration 4: "BookingType Should Be Removed" ✅ + +**My Miss:** +I created `BookingType` union even though `IBooking` no longer had a `type` field. + +**Developer Catch:** +> "But wait... booking type is gone, right? It's always customer-related." + +**Fix Applied:** +- Renamed `BookingType` → `CalendarEventType` +- Clarified: Only `ICalendarEvent` uses this type, not `IBooking` +- Updated all references + +**Result:** ✅ Type system now consistent + +--- + +## Key Architectural Decisions + +### Decision 1: Booking = Customer ONLY + +**Before:** Booking could be customer, vacation, break, meeting +**After:** Booking is ONLY for customer services + +```typescript +// ✅ Valid +{ + "customerId": "CUST456", + "services": [{ "resourceId": "EMP001" }] +} + +// ❌ Invalid - vacation is NOT a booking +{ + "type": "vacation", + "resourceId": "EMP001" +} +``` + +**Vacation is now:** +```typescript +{ + "type": "vacation", + "bookingId": null, // NO booking + "resourceId": "EMP001" // Just a time slot +} +``` + +--- + +### Decision 2: Resources Assigned Per Service + +**Before:** Booking.resourceId (primary) +**After:** IBookingService.resourceId (per service) + +**Enables:** +- Split resources: student + master +- Equal splits: Karina + Nanna +- Resource reassignment: "student is sick, assign to another student" + +**Example - Split Resource Booking:** +```json +{ + "customerId": "CUST456", + "services": [ + { + "serviceName": "Hårvask", + "resourceId": "STUDENT001", + "baseDuration": 30 + }, + { + "serviceName": "Bundfarve", + "resourceId": "EMP001", + "baseDuration": 90 + } + ] +} +``` + +Two CalendarEvents created: +- Event 1: Student's calendar, 13:00-13:30 +- Event 2: Master's calendar, 13:30-15:00 +- Both reference same booking + +--- + +### Decision 3: 1 Booking → N Events + +**Relationship:** One booking can span multiple events + +**Scenarios:** +1. **Split Session:** 180min service split across 2 days (same resource) +2. **Split Resources:** Student + Master (different resources) +3. **Equal Split:** Karina + Nanna (two masters, equal) + +**Key Insight:** Events are scheduled per resource, not per booking. + +--- + +### Decision 4: CalendarEvent.type Determines Booking Existence + +```typescript +type CalendarEventType = + | 'customer' // HAS booking + | 'vacation' // NO booking + | 'break' // NO booking + | 'meeting' // NO booking + | 'blocked'; // NO booking +``` + +**Business Rule:** If `event.type === 'customer'`, then `event.bookingId` must be set. + +--- + +## Database Schema Changes + +### Before (Iteration 1 - Wrong) +```sql +CREATE TABLE Booking ( + ResourceId VARCHAR(50) NOT NULL, -- ❌ Wrong: assumes one resource + CustomerId VARCHAR(50) NULL, -- ❌ Wrong: should be required + Type VARCHAR(20) NOT NULL -- ❌ Wrong: always customer +); + +CREATE TABLE BookingService ( + BookingId VARCHAR(50) NOT NULL, + ServiceId VARCHAR(50) NOT NULL + -- ❌ Missing: ResourceId +); +``` + +### After (Final Design - Correct) +```sql +CREATE TABLE Booking ( + Id VARCHAR(50) PRIMARY KEY, + CustomerId VARCHAR(50) NOT NULL, -- ✅ REQUIRED + Status VARCHAR(20) NOT NULL, + TotalPrice DECIMAL(10,2) NULL, + -- ✅ NO ResourceId (moved to service level) + -- ✅ NO Type (always customer) + + FOREIGN KEY (CustomerId) REFERENCES Customer(Id) +); + +CREATE TABLE BookingService ( + BookingId VARCHAR(50) NOT NULL, + ServiceId VARCHAR(50) NOT NULL, + ResourceId VARCHAR(50) NOT NULL, -- ✅ NEW: Resource per service + CustomPrice DECIMAL(10,2) NULL, + SortOrder INT NULL, + + FOREIGN KEY (BookingId) REFERENCES Booking(Id), + FOREIGN KEY (ServiceId) REFERENCES Service(Id), + FOREIGN KEY (ResourceId) REFERENCES Resource(Id) -- ✅ NEW FK +); + +CREATE TABLE CalendarEvent ( + Id VARCHAR(50) PRIMARY KEY, + Start DATETIME NOT NULL, + End DATETIME NOT NULL, + Type VARCHAR(50) NOT NULL, -- ✅ Distinguishes customer vs vacation + BookingId VARCHAR(50) NULL, -- ✅ Nullable (only customer has booking) + ResourceId VARCHAR(50) NULL, -- ✅ Denormalized for performance + CustomerId VARCHAR(50) NULL, -- ✅ Denormalized for performance + + FOREIGN KEY (BookingId) REFERENCES Booking(Id) +); +``` + +--- + +## Code Changes Summary + +### Files Created (3) +1. **src/types/ResourceTypes.ts** - IResource interface +2. **src/types/BookingTypes.ts** - IBooking + IBookingService + CalendarEventType +3. **src/types/CustomerTypes.ts** - ICustomer interface + +### Files Modified (2) +4. **src/types/ColumnDataSource.ts** - Changed `Date | any` to `Date | IResource` +5. **src/types/CalendarTypes.ts** - Updated ICalendarEvent to use CalendarEventType + +### Documentation Created (1) +6. **docs/booking-event-architecture.md** - 930 lines comprehensive guide + - 6 detailed examples + - Database schemas (SQL + IndexedDB) + - 5 business rules + - 4 common pitfalls + - Analytics patterns + - Query examples + +--- + +## Statistics + +| Metric | Count | +|--------|-------| +| **Time Spent** | 2+ hours | +| **Initial Estimate** | 10 minutes | +| **Major Iterations** | 4 | +| **Architectural Pivots** | 3 | +| **Developer Catches** | 5+ critical issues | +| **Interfaces Created** | 5 (IResource, IBooking, IBookingService, ICustomer, CalendarEventType) | +| **Interfaces Modified** | 2 (IColumnInfo, ICalendarEvent) | +| **Documentation Written** | 930 lines | +| **Examples Documented** | 6 scenarios | +| **Business Rules Defined** | 5 | +| **Common Pitfalls Identified** | 4 | +| **Database Tables Changed** | 3 (Booking, BookingService, CalendarEvent) | + +--- + +## Developer Interventions (Critical Thinking Points) + +### 1. "What if multiple resources work on same booking?" +**Prevented:** Single-resource assumption that would block split bookings + +### 2. "Why should there be a primary resource?" +**Prevented:** Artificial hierarchy that doesn't match business reality + +### 3. "Is vacation a booking?" +**Resulted in:** Clean separation - Booking = customers only, Events = everything + +### 4. "If the student is sick?" +**Resulted in:** Service-level resource assignment enabling reassignment + +### 5. "BookingType is gone now, right?" +**Resulted in:** Consistent naming - CalendarEventType for events, not bookings + +--- + +## What We Almost Built (And Avoided) + +### ❌ Wrong Architecture 1: Primary Resource Model +Would have required: +- Complex logic to determine "who is primary" +- Arbitrary decision making (is master or student primary?) +- Doesn't support equal splits (Karina + Nanna) + +### ❌ Wrong Architecture 2: Booking.resourceId + Service.resourceId +Would have resulted in: +- Redundant data +- Confusion about which resourceId to use +- "Primary" concept that doesn't exist in business logic + +### ❌ Wrong Architecture 3: Vacation as Booking +Would have created: +- Optional fields everywhere (services? customer? for vacation?) +- Complex validation logic +- Confusing domain model + +--- + +## Final Architecture Benefits + +### ✅ Clean Separation of Concerns +- **Booking** = Customer + Services (WHAT + HOW MUCH) +- **CalendarEvent** = Time + Resource (WHEN + WHO) + +### ✅ Supports All Business Scenarios +1. Simple booking (one customer, one service, one resource) +2. Split session (one booking, multiple days) +3. Split resources (student + master) +4. Equal splits (two masters, no primary) +5. Queued bookings (no time slot yet) +6. Vacation/break (no booking, just time blocking) + +### ✅ Type-Safe +- Required fields are enforced: `customerId: string`, `services: IBookingService[]` +- No optional fields that "sometimes" matter +- CalendarEventType clearly distinguishes booking vs non-booking events + +### ✅ Flexible Resource Assignment +- Assign different resources per service +- Reassign if resource unavailable (student sick) +- No "primary" to maintain + +### ✅ Query-Optimized +- CalendarEvent denormalizes resourceId for fast queries +- No JOIN needed to find "Karina's events" +- IndexedDB-friendly (no JOIN support) + +--- + +## Lessons Learned + +### 1. Question Assumptions Early +**My assumption:** "Bookings are resource-bound" +**Reality:** Bookings are customer-bound, resources are service-bound + +### 2. Use Real Business Scenarios +Abstract discussions led to wrong models. Concrete examples ("student + master split") revealed the truth. + +### 3. Don't Force Hierarchies +"Primary resource" seemed logical but didn't match business reality. Equal splits exist. + +### 4. Separation of Concerns Matters +Mixing booking (business) with scheduling (time) created optional fields everywhere. + +### 5. Developer Domain Knowledge is Critical +I had TypeScript expertise, but developer had business domain knowledge. The combination caught 5+ critical flaws before they became code. + +--- + +## Documentation Deliverables + +### booking-event-architecture.md (930 lines) + +**Sections:** +1. Core Principles - Separation of concerns +2. Entity Definitions - Booking vs CalendarEvent +3. Relationship Model - 1:N (one booking → many events) +4. 6 Detailed Examples: + - Split session (same resource, multiple days) + - Split resources (master + student) + - Equal split (two masters) + - Queued booking + - Vacation (no booking) + - Simple reminder +5. Database Schema - SQL (normalized) + IndexedDB (denormalized) +6. API Data Flow - Backend JOIN → Frontend denormalization +7. Business Rules - 5 rules with code examples +8. Analytics & Reporting - Service duration vs actual time +9. TypeScript Interfaces - Complete reference +10. Common Pitfalls - 4 mistakes to avoid +11. Summary Table - Quick reference +12. Decision Guide - "Where does this belong?" + +--- + +## Build Results + +✅ **All TypeScript compilation successful** +- 57 files processed +- 0 errors +- 0 warnings +- Build time: ~1.2s + +✅ **Type safety improved** +- Removed `Date | any` (was unsafe) +- Added `Date | IResource` (type-safe union) +- Added CalendarEventType (was `string`) + +--- + +## Conclusion + +**Initial request:** "Create IResource interface" +**Time estimated:** 10 minutes +**Time actual:** 2+ hours + +**Why the difference?** + +Because the developer asked the right questions at the right time: +1. "What if multiple resources work together?" +2. "Why assume a primary?" +3. "Is vacation really a booking?" +4. "What if we need to reassign?" + +Each question revealed a flaw in my assumptions. We iterated 4 times, discarded 3 approaches, and arrived at an architecture that actually matches the business model. + +**This is exactly how good software development should work.** + +The 2 hours spent thinking prevented weeks of refactoring later. Critical thinking and domain knowledge prevented premature optimization and over-engineering. + +--- + +## Next Steps (Not Part of This Session) + +**Phase 2: Resource Calendar Implementation** +- Create ResourceColumnDataSource (returns IColumnInfo with IResource data) +- Create ResourceHeaderRenderer (renders resource headers instead of dates) +- Implement resource-mode switching (date-mode ↔ resource-mode) +- Add repository methods for resource queries + +**Phase 3: Booking Management** +- Implement booking creation flow +- Service-to-event mapping logic +- Split-resource UI (assign services to different resources) +- Resource reassignment (when student is sick) + +--- + +**Session End** + +**Key Takeaway:** Slow down to speed up. 2 hours of architecture discussion saved weeks of wrong implementation. diff --git a/docs/booking-event-architecture.md b/docs/booking-event-architecture.md new file mode 100644 index 0000000..dc972b9 --- /dev/null +++ b/docs/booking-event-architecture.md @@ -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; +} +``` + +--- + +## 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** diff --git a/src/types/BookingTypes.ts b/src/types/BookingTypes.ts new file mode 100644 index 0000000..17bac5e --- /dev/null +++ b/src/types/BookingTypes.ts @@ -0,0 +1,63 @@ +/** + * Booking entity - represents customer service bookings ONLY + * + * IMPORTANT ARCHITECTURE: + * - Booking = Customer + Services (business container) + * - Booking does NOT have start/end - timing is on CalendarEvent + * - Booking does NOT have resourceId - resources assigned per service + * - One booking can have multiple CalendarEvents (split sessions or resources) + * - Booking can exist without events (queued/pending state) + * - Vacation/break/meeting are NOT bookings - only CalendarEvents + * + * Resource Assignment: + * - Resources are assigned at SERVICE level (IBookingService.resourceId) + * - One service can be performed by one resource + * - Multiple services can be performed by different resources + * - Example: Hårvask by Student, Bundfarve by Master (same booking, 2 resources) + * + * Matches backend Booking table structure + */ +export interface IBooking { + id: string; + customerId: string; // REQUIRED - booking is always for a customer + status: BookingStatus; + createdAt: Date; + + // Services (REQUIRED - booking always has services) + services: IBookingService[]; + + // Payment + totalPrice?: number; // Can differ from sum of service prices + + // Metadata + tags?: string[]; + notes?: string; +} + +/** + * CalendarEventType - Used by ICalendarEvent.type + * Note: Only 'customer' events have associated IBooking + * Other types (vacation/break/meeting/blocked) are CalendarEvents without bookings + */ +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 + +export interface IBookingService { + serviceId: string; + serviceName: string; // Denormalized for display + baseDuration: number; // Minutes (from Service.duration) + basePrice: number; // From Service.basePrice + customPrice?: number; // Override if different from basePrice + resourceId: string; // Resource who performs THIS service +} diff --git a/src/types/CalendarTypes.ts b/src/types/CalendarTypes.ts index 9c7cb50..2655dfa 100644 --- a/src/types/CalendarTypes.ts +++ b/src/types/CalendarTypes.ts @@ -1,4 +1,5 @@ // Calendar type definitions +import { CalendarEventType } from './BookingTypes'; // Time period view types (how much time to display) export type ViewPeriod = 'day' | 'week' | 'month'; @@ -20,10 +21,15 @@ export interface ICalendarEvent { description?: string; start: Date; end: Date; - type: string; // Flexible event type - can be any string value + type: CalendarEventType; // Event type - only 'customer' has associated booking allDay: boolean; syncStatus: SyncStatus; + // References (denormalized for IndexedDB performance) + bookingId?: string; // Reference to booking (only if type = 'customer') + resourceId?: string; // Resource who owns this time slot + customerId?: string; // Denormalized from Booking.customerId + recurringId?: string; metadata?: Record; } diff --git a/src/types/ColumnDataSource.ts b/src/types/ColumnDataSource.ts index 16c12c9..ad95d98 100644 --- a/src/types/ColumnDataSource.ts +++ b/src/types/ColumnDataSource.ts @@ -1,10 +1,12 @@ +import { IResource } from './ResourceTypes'; + /** * Column information container * Contains both identifier and actual data for a column */ export interface IColumnInfo { identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode) - data: Date | any; // Date for date-mode, IResource for resource-mode + data: Date | IResource; // Date for date-mode, IResource for resource-mode } /** diff --git a/src/types/CustomerTypes.ts b/src/types/CustomerTypes.ts new file mode 100644 index 0000000..5ffd1b1 --- /dev/null +++ b/src/types/CustomerTypes.ts @@ -0,0 +1,11 @@ +/** + * Customer entity + * Matches backend Customer table structure + */ +export interface ICustomer { + id: string; + name: string; + phone: string; + email?: string; + metadata?: Record; +} diff --git a/src/types/ResourceTypes.ts b/src/types/ResourceTypes.ts new file mode 100644 index 0000000..8650d4f --- /dev/null +++ b/src/types/ResourceTypes.ts @@ -0,0 +1,21 @@ +/** + * Resource entity - represents people, rooms, equipment, etc. + * Matches backend Resource table structure + */ +export interface IResource { + id: string; // Primary key (e.g., "EMP001", "ROOM-A") + name: string; // Machine name (e.g., "karina.knudsen") + displayName: string; // Human-readable name (e.g., "Karina Knudsen") + type: ResourceType; // Resource category + avatarUrl?: string; // Avatar/icon URL (e.g., "/avatars/karina.jpg") + color?: string; // Color for visual distinction in calendar + isActive?: boolean; // Whether resource is currently active + metadata?: Record; // Flexible extension point +} + +export type ResourceType = + | 'person' // Employees, team members + | 'room' // Meeting rooms, offices + | 'equipment' // Shared equipment, tools + | 'vehicle' // Company vehicles + | 'custom'; // User-defined types