From a360fad265919cb20308bf54a252dfd8a594d626 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Fri, 14 Nov 2025 23:05:57 +0100 Subject: [PATCH] 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 --- ...-booking-resource-architecture-redesign.md | 522 ++++++++++ docs/booking-event-architecture.md | 930 ++++++++++++++++++ src/types/BookingTypes.ts | 63 ++ src/types/CalendarTypes.ts | 8 +- src/types/ColumnDataSource.ts | 4 +- src/types/CustomerTypes.ts | 11 + src/types/ResourceTypes.ts | 21 + 7 files changed, 1557 insertions(+), 2 deletions(-) create mode 100644 coding-sessions/2025-11-14-booking-resource-architecture-redesign.md create mode 100644 docs/booking-event-architecture.md create mode 100644 src/types/BookingTypes.ts create mode 100644 src/types/CustomerTypes.ts create mode 100644 src/types/ResourceTypes.ts 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