diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index b8def76..2206350 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -1,12 +1,7 @@
{
"permissions": {
"allow": [
- "Bash(npm run build:*)",
- "WebSearch",
- "WebFetch(domain:web.dev)",
- "WebFetch(domain:caniuse.com)",
- "WebFetch(domain:blog.rasc.ch)",
- "WebFetch(domain:developer.chrome.com)"
+ "Bash(npm run build:*)"
],
"deny": [],
"ask": []
diff --git a/.workbench/event-colors.txt b/.workbench/event-colors.txt
deleted file mode 100644
index 8c07e65..0000000
--- a/.workbench/event-colors.txt
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
-
-
- Event Farvesystem Demo
-
-
-
-
-Event Farvesystem Demo
-Baggrunden er dæmpet primærfarve, hover gør den mørkere, venstre kant og tekst bruger den rene farve.
-
-
-
-
-
-
-
-
-
-
Grøn event
-
.is-green
-
-
-
-
-
-
Magenta event
-
.is-magenta
-
-
-
-
-
-
Amber event
-
.is-amber
-
-
-
-
-
-
Orange event
-
.is-orange
-
-
-
-
-
-
-
-
diff --git a/coding-sessions/2025-11-20-repository-elimination-indexeddb-refactoring.md b/coding-sessions/2025-11-20-repository-elimination-indexeddb-refactoring.md
deleted file mode 100644
index a54bfb0..0000000
--- a/coding-sessions/2025-11-20-repository-elimination-indexeddb-refactoring.md
+++ /dev/null
@@ -1,903 +0,0 @@
-# Repository Layer Elimination & IndexedDB Architecture Refactoring
-
-**Date:** 2025-11-20
-**Duration:** ~6 hours
-**Initial Scope:** Create Mock repositories and implement data seeding
-**Actual Scope:** Complete repository layer elimination, IndexedDB context refactoring, and direct service usage pattern
-
----
-
-## Executive Summary
-
-Eliminated redundant repository abstraction layer (IndexedDBEventRepository, IEventRepository) and established direct EventService usage pattern. Renamed IndexedDBService → IndexedDBContext to better reflect its role as connection provider. Implemented DataSeeder for initial data loading from Mock repositories.
-
-**Key Achievements:**
-- ✅ Created 4 Mock repositories (Event, Booking, Customer, Resource) for development
-- ✅ Implemented DataSeeder with polymorphic array-based architecture
-- ✅ Eliminated repository wrapper layer (200+ lines removed)
-- ✅ Renamed IndexedDBService → IndexedDBContext (better separation of concerns)
-- ✅ Fixed IDBDatabase injection timing issue with lazy access pattern
-- ✅ EventManager now uses EventService directly via BaseEntityService methods
-
-**Critical Success Factor:** Multiple architectural mistakes were caught and corrected through experienced code review. Without senior-level oversight, this session would have resulted in severely compromised architecture.
-
----
-
-## Context: Starting Point
-
-### Previous Work (Nov 18, 2025)
-Hybrid Entity Service Pattern session established:
-- BaseEntityService with generic CRUD operations
-- SyncPlugin composition for sync status management
-- 4 entity services (Event, Booking, Customer, Resource) all extending base
-- 75% code reduction through inheritance
-
-### The Gap
-After hybrid pattern implementation, we had:
-- ✅ Services working (BaseEntityService + SyncPlugin)
-- ❌ No actual data in IndexedDB
-- ❌ No way to load mock data for development
-- ❌ Unclear repository vs service responsibilities
-- ❌ IndexedDBService doing too many things (connection + queue + sync state)
-
----
-
-## Session Evolution: Major Architectural Decisions
-
-### Phase 1: Mock Repositories Creation ✅
-
-**Goal:** Create development repositories that load from JSON files instead of API.
-
-**Implementation:**
-Created 4 mock repositories implementing `IApiRepository`:
-1. **MockEventRepository** - loads from `data/mock-events.json`
-2. **MockBookingRepository** - loads from `data/mock-bookings.json`
-3. **MockCustomerRepository** - loads from `data/mock-customers.json`
-4. **MockResourceRepository** - loads from `data/mock-resources.json`
-
-**Architecture:**
-```typescript
-export class MockEventRepository implements IApiRepository {
- readonly entityType: EntityType = 'Event';
- private readonly dataUrl = 'data/mock-events.json';
-
- async fetchAll(): Promise {
- const response = await fetch(this.dataUrl);
- const rawData: RawEventData[] = await response.json();
- return this.processCalendarData(rawData);
- }
-
- // Create/Update/Delete throw "read-only" errors
- async sendCreate(event: ICalendarEvent): Promise {
- throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
- }
-}
-```
-
-**Key Pattern:** Repositories responsible for data fetching ONLY, not storage.
-
----
-
-### Phase 2: Critical Bug - Missing RawEventData Fields 🐛
-
-**Discovery:**
-Mock JSON files contained fields not declared in RawEventData interface:
-```json
-{
- "id": "event-1",
- "bookingId": "BOOK001", // ❌ Not in interface
- "resourceId": "EMP001", // ❌ Not in interface
- "customerId": "CUST001", // ❌ Not in interface
- "description": "..." // ❌ Not in interface
-}
-```
-
-**User Feedback:** *"This is unacceptable - you've missed essential fields that are critical for the booking architecture."*
-
-**Root Cause:** RawEventData interface was incomplete, causing type mismatch with actual JSON structure.
-
-**Fix Applied:**
-```typescript
-interface RawEventData {
- // Core fields (required)
- id: string;
- title: string;
- start: string | Date;
- end: string | Date;
- type: string;
- allDay?: boolean;
-
- // Denormalized references (CRITICAL for booking architecture) ✅ ADDED
- bookingId?: string;
- resourceId?: string;
- customerId?: string;
-
- // Optional fields ✅ ADDED
- description?: string;
- recurringId?: string;
- metadata?: Record;
-}
-```
-
-**Validation Added:**
-```typescript
-private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
- return data.map((event): ICalendarEvent => {
- if (event.type === 'customer') {
- if (!event.bookingId) console.warn(`Customer event ${event.id} missing bookingId`);
- if (!event.resourceId) console.warn(`Customer event ${event.id} missing resourceId`);
- if (!event.customerId) console.warn(`Customer event ${event.id} missing customerId`);
- }
- // ... map to ICalendarEvent
- });
-}
-```
-
-**Lesson:** Interface definitions must match actual data structure. Type safety only works if types are correct.
-
----
-
-### Phase 3: DataSeeder Initial Implementation ⚠️
-
-**Goal:** Orchestrate data flow from repositories to IndexedDB via services.
-
-**Initial Attempt (WRONG):**
-```typescript
-export class DataSeeder {
- constructor(
- private eventService: EventService,
- private bookingService: BookingService,
- private customerService: CustomerService,
- private resourceService: ResourceService,
- private eventRepository: IApiRepository,
- // ... more individual injections
- ) {}
-
- async seedIfEmpty(): Promise {
- await this.seedEntity('Event', this.eventService, this.eventRepository);
- await this.seedEntity('Booking', this.bookingService, this.bookingRepository);
- // ... manual calls for each entity
- }
-}
-```
-
-**User Feedback:** *"Instead of all these separate injections, why not use arrays of IEntityService?"*
-
-**Problem:** Constructor had 8 individual dependencies instead of using polymorphic array injection.
-
----
-
-### Phase 4: DataSeeder Polymorphic Refactoring ✅
-
-**Corrected Architecture:**
-```typescript
-export class DataSeeder {
- constructor(
- // Arrays injected via DI - automatically includes all registered services/repositories
- private services: IEntityService[],
- private repositories: IApiRepository[]
- ) {}
-
- async seedIfEmpty(): Promise {
- // Loop through all entity services
- for (const service of this.services) {
- // Match service with repository by entityType
- const repository = this.repositories.find(repo => repo.entityType === service.entityType);
-
- if (!repository) {
- console.warn(`No repository found for entity type: ${service.entityType}`);
- continue;
- }
-
- await this.seedEntity(service.entityType, service, repository);
- }
- }
-
- private async seedEntity(
- entityType: string,
- service: IEntityService,
- repository: IApiRepository
- ): Promise {
- const existing = await service.getAll();
- if (existing.length > 0) return; // Already seeded
-
- const data = await repository.fetchAll();
- for (const entity of data) {
- await service.save(entity);
- }
- }
-}
-```
-
-**Benefits:**
-- Open/Closed Principle: Adding new entity requires zero DataSeeder code changes
-- NovaDI automatically injects all `IEntityService[]` and `IApiRepository[]`
-- Runtime matching via `entityType` property
-- Scales to any number of entities
-
-**DI Registration:**
-```typescript
-// index.ts
-builder.registerType(EventService).as>();
-builder.registerType(BookingService).as>();
-builder.registerType(CustomerService).as>();
-builder.registerType(ResourceService).as>();
-
-builder.registerType(MockEventRepository).as>();
-// ... NovaDI builds arrays automatically
-```
-
----
-
-### Phase 5: IndexedDBService Naming & Responsibility Crisis 🚨
-
-**The Realization:**
-```typescript
-// IndexedDBService was doing THREE things:
-class IndexedDBService {
- private db: IDBDatabase; // 1. Connection management
-
- async addToQueue() { ... } // 2. Queue operations
- async getQueue() { ... }
-
- async setSyncState() { ... } // 3. Sync state operations
- async getSyncState() { ... }
-}
-```
-
-**User Question:** *"If IndexedDBService's primary responsibility is now to hold and share the IDBDatabase instance, is the name still correct?"*
-
-**Architectural Discussion:**
-
-**Option 1:** Keep name, accept broader responsibility
-**Option 2:** Rename to DatabaseConnection/IndexedDBConnection
-**Option 3:** Rename to IndexedDBContext + move queue/sync to OperationQueue
-
-**Decision:** Option 3 - IndexedDBContext + separate concerns
-
-**User Directive:** *"Queue and sync operations should move to OperationQueue, and IndexedDBService should be renamed to IndexedDBContext."*
-
----
-
-### Phase 6: IndexedDBContext Refactoring ✅
-
-**Goal:** Single Responsibility - connection management only.
-
-**Created: IndexedDBContext.ts**
-```typescript
-export class IndexedDBContext {
- private static readonly DB_NAME = 'CalendarDB';
- private db: IDBDatabase | null = null;
- private initialized: boolean = false;
-
- async initialize(): Promise {
- // Opens database, creates stores
- }
-
- public getDatabase(): IDBDatabase {
- if (!this.db) {
- throw new Error('IndexedDB not initialized. Call initialize() first.');
- }
- return this.db;
- }
-
- close(): void { ... }
- static async deleteDatabase(): Promise { ... }
-}
-```
-
-**Moved to OperationQueue.ts:**
-```typescript
-export interface IQueueOperation { ... } // Moved from IndexedDBService
-
-export class OperationQueue {
- constructor(private context: IndexedDBContext) {}
-
- // Queue operations (moved from IndexedDBService)
- async enqueue(operation: Omit): Promise {
- const db = this.context.getDatabase();
- // ... direct IndexedDB operations
- }
-
- async getAll(): Promise { ... }
- async remove(operationId: string): Promise { ... }
- async clear(): Promise { ... }
-
- // Sync state operations (moved from IndexedDBService)
- async setSyncState(key: string, value: any): Promise { ... }
- async getSyncState(key: string): Promise { ... }
-}
-```
-
-**Benefits:**
-- Clear names: Context = connection provider, Queue = queue operations
-- Better separation of concerns
-- OperationQueue owns all queue-related logic
-- Context focuses solely on database lifecycle
-
----
-
-### Phase 7: IDBDatabase Injection Timing Problem 🐛
-
-**The Discovery:**
-
-Services were using this pattern:
-```typescript
-export abstract class BaseEntityService {
- protected db: IDBDatabase;
-
- constructor(db: IDBDatabase) { // ❌ Problem: db not ready yet
- this.db = db;
- }
-}
-```
-
-**Problem:** DI flow with timing issue:
-```
-1. container.build()
- ↓
-2. Services instantiated (constructor runs) ← db is NULL!
- ↓
-3. indexedDBContext.initialize() ← db created NOW
- ↓
-4. Services try to use db ← too late!
-```
-
-**User Question:** *"Isn't it a problem that services are instantiated before the database is initialized?"*
-
-**Solution: Lazy Access Pattern**
-
-```typescript
-export abstract class BaseEntityService {
- private context: IndexedDBContext;
-
- constructor(context: IndexedDBContext) { // ✅ Inject context
- this.context = context;
- }
-
- protected get db(): IDBDatabase { // ✅ Lazy getter
- return this.context.getDatabase(); // Requested when used, not at construction
- }
-
- async get(id: string): Promise {
- // First access to this.db calls getter → context.getDatabase()
- const transaction = this.db.transaction([this.storeName], 'readonly');
- // ...
- }
-}
-```
-
-**Why It Works:**
-- Constructor: Services get `IndexedDBContext` reference (immediately available)
-- Usage: `this.db` getter calls `context.getDatabase()` when actually needed
-- Timing: By the time services use `this.db`, database is already initialized
-
-**Updated Initialization Flow:**
-```
-1. container.build()
-2. Services instantiated (store context reference)
-3. indexedDBContext.initialize() ← database ready
-4. dataSeeder.seedIfEmpty() ← calls service.getAll()
- ↓ First this.db access
- ↓ Getter calls context.getDatabase()
- ↓ Returns ready IDBDatabase
-5. CalendarManager.initialize()
-```
-
----
-
-### Phase 8: Repository Layer Elimination Decision 🎯
-
-**The Critical Realization:**
-
-User examined IndexedDBEventRepository:
-```typescript
-export class IndexedDBEventRepository implements IEventRepository {
- async createEvent(event: Omit): Promise {
- const id = `event-${Date.now()}-${Math.random()}`;
- const newEvent = { ...event, id, syncStatus: 'pending' };
- await this.eventService.save(newEvent); // Just calls service.save()
- await this.queue.enqueue({...}); // Queue logic (ignore for now)
- return newEvent;
- }
-
- async updateEvent(id: string, updates: Partial): Promise {
- const existing = await this.eventService.get(id);
- const updated = { ...existing, ...updates };
- await this.eventService.save(updated); // Just calls service.save()
- return updated;
- }
-
- async deleteEvent(id: string): Promise {
- await this.eventService.delete(id); // Just calls service.delete()
- }
-}
-```
-
-**User Observation:** *"If BaseEntityService already has save() and delete(), why do we need createEvent() and updateEvent()? They should just be deleted. And deleteEvent() should also be deleted - we use service.delete() directly."*
-
-**The Truth:**
-- `createEvent()` → generate ID + `service.save()` (redundant wrapper)
-- `updateEvent()` → merge + `service.save()` (redundant wrapper)
-- `deleteEvent()` → `service.delete()` (redundant wrapper)
-- Queue logic → not implemented yet, so it's dead code
-
-**Decision:** Eliminate entire repository layer.
-
----
-
-### Phase 9: Major Architectural Mistake - Attempted Wrong Solution ❌
-
-**My Initial (WRONG) Proposal:**
-```typescript
-// WRONG: I suggested moving createEvent/updateEvent TO EventService
-export class EventService extends BaseEntityService {
- async createEvent(event: Omit): Promise {
- const id = generateId();
- return this.save({ ...event, id });
- }
-
- async updateEvent(id: string, updates: Partial): Promise {
- const existing = await this.get(id);
- return this.save({ ...existing, ...updates });
- }
-}
-```
-
-**User Response:** *"This makes no sense. If BaseEntityService already has save(), we don't need createEvent. And updateEvent is just get + save. They should be DELETED, not moved."*
-
-**The Correct Understanding:**
-- EventService already has `save()` (upsert - creates OR updates)
-- EventService already has `delete()` (removes entity)
-- EventService already has `getAll()` (loads all entities)
-- EventManager should call these methods directly
-
-**Correct Solution:**
-1. Delete IndexedDBEventRepository.ts entirely
-2. Delete IEventRepository.ts entirely
-3. Update EventManager to inject EventService directly
-4. EventManager calls `eventService.save()` / `eventService.delete()` directly
-
----
-
-### Phase 10: EventManager Direct Service Usage ✅
-
-**Before (with repository wrapper):**
-```typescript
-export class EventManager {
- constructor(
- private repository: IEventRepository
- ) {}
-
- async addEvent(event: Omit): Promise {
- return await this.repository.createEvent(event, 'local');
- }
-
- async updateEvent(id: string, updates: Partial): Promise {
- return await this.repository.updateEvent(id, updates, 'local');
- }
-
- async deleteEvent(id: string): Promise {
- await this.repository.deleteEvent(id, 'local');
- return true;
- }
-}
-```
-
-**After (direct service usage):**
-```typescript
-export class EventManager {
- private eventService: EventService;
-
- constructor(
- eventBus: IEventBus,
- dateService: DateService,
- config: Configuration,
- eventService: IEntityService // Interface injection
- ) {
- this.eventService = eventService as EventService; // Typecast to access event-specific methods
- }
-
- async addEvent(event: Omit): Promise {
- const id = `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
- const newEvent: ICalendarEvent = {
- ...event,
- id,
- syncStatus: 'synced' // No queue yet
- };
- await this.eventService.save(newEvent); // ✅ Direct save
-
- this.eventBus.emit(CoreEvents.EVENT_CREATED, { event: newEvent });
- return newEvent;
- }
-
- async updateEvent(id: string, updates: Partial): Promise {
- const existing = await this.eventService.get(id); // ✅ Direct get
- if (!existing) throw new Error(`Event ${id} not found`);
-
- const updated: ICalendarEvent = {
- ...existing,
- ...updates,
- id,
- syncStatus: 'synced'
- };
- await this.eventService.save(updated); // ✅ Direct save
-
- this.eventBus.emit(CoreEvents.EVENT_UPDATED, { event: updated });
- return updated;
- }
-
- async deleteEvent(id: string): Promise {
- await this.eventService.delete(id); // ✅ Direct delete
-
- this.eventBus.emit(CoreEvents.EVENT_DELETED, { eventId: id });
- return true;
- }
-}
-```
-
-**Code Reduction:**
-- IndexedDBEventRepository: 200+ lines → DELETED
-- IEventRepository: 50+ lines → DELETED
-- EventManager: Simpler, direct method calls
-
----
-
-### Phase 11: DI Injection Final Problem 🐛
-
-**Build Error:**
-```
-BindingNotFoundError: Token "Token" is not bound or registered in the container.
-Dependency path: Token -> Token
-```
-
-**Root Cause:**
-```typescript
-// index.ts - EventService registered as interface
-builder.registerType(EventService).as>();
-
-// EventManager.ts - trying to inject concrete type
-constructor(
- eventService: EventService // ❌ Can't resolve concrete type
-) {}
-```
-
-**Initial Mistake:** I suggested registering EventService twice (as both interface and concrete type).
-
-**User Correction:** *"Don't you understand generic interfaces? It's registered as IEntityService. Can't you just inject the interface and typecast in the assignment?"*
-
-**The Right Solution:**
-```typescript
-export class EventManager {
- private eventService: EventService; // Property: concrete type
-
- constructor(
- private eventBus: IEventBus,
- dateService: DateService,
- config: Configuration,
- eventService: IEntityService // Parameter: interface (DI can resolve)
- ) {
- this.dateService = dateService;
- this.config = config;
- this.eventService = eventService as EventService; // Typecast to concrete
- }
-}
-```
-
-**Why This Works:**
-- DI injects `IEntityService` (registered interface)
-- Property is `EventService` type (access to event-specific methods like `getByDateRange()`)
-- Runtime: It's actually EventService instance anyway (safe cast)
-- TypeScript: Explicit cast required (no implicit downcast from interface to concrete)
-
----
-
-## Files Changed Summary
-
-### Files Created (3)
-1. **src/repositories/MockEventRepository.ts** (122 lines) - JSON-based event data
-2. **src/repositories/MockBookingRepository.ts** (95 lines) - JSON-based booking data
-3. **src/repositories/MockCustomerRepository.ts** (58 lines) - JSON-based customer data
-4. **src/repositories/MockResourceRepository.ts** (67 lines) - JSON-based resource data
-5. **src/workers/DataSeeder.ts** (103 lines) - Polymorphic data seeding orchestrator
-6. **src/storage/IndexedDBContext.ts** (127 lines) - Database connection provider
-
-### Files Deleted (2)
-7. **src/repositories/IndexedDBEventRepository.ts** (200+ lines) - Redundant wrapper
-8. **src/repositories/IEventRepository.ts** (50+ lines) - Unnecessary interface
-
-### Files Modified (8)
-9. **src/repositories/MockEventRepository.ts** - Fixed RawEventData interface (added bookingId, resourceId, customerId, description)
-10. **src/storage/OperationQueue.ts** - Moved IQueueOperation interface, added queue + sync state operations
-11. **src/storage/BaseEntityService.ts** - Changed injection from IDBDatabase to IndexedDBContext, added lazy getter
-12. **src/managers/EventManager.ts** - Removed repository, inject EventService, direct method calls
-13. **src/workers/SyncManager.ts** - Removed IndexedDBService dependency
-14. **src/index.ts** - Updated DI registrations (removed IEventRepository, added DataSeeder)
-15. **wwwroot/data/mock-events.json** - Copied from events.json
-16. **wwwroot/data/mock-bookings.json** - Copied from bookings.json
-17. **wwwroot/data/mock-customers.json** - Copied from customers.json
-18. **wwwroot/data/mock-resources.json** - Copied from resources.json
-
-### File Renamed (1)
-19. **src/storage/IndexedDBService.ts** → **src/storage/IndexedDBContext.ts**
-
----
-
-## Architecture Evolution Diagram
-
-**BEFORE:**
-```
-EventManager
- ↓
-IEventRepository (interface)
- ↓
-IndexedDBEventRepository (wrapper)
- ↓
-EventService
- ↓
-BaseEntityService
- ↓
-IDBDatabase
-```
-
-**AFTER:**
-```
-EventManager
- ↓ (inject IEntityService)
- ↓ (typecast to EventService)
- ↓
-EventService
- ↓
-BaseEntityService
- ↓ (inject IndexedDBContext)
- ↓ (lazy getter)
- ↓
-IndexedDBContext.getDatabase()
- ↓
-IDBDatabase
-```
-
-**Removed Layers:**
-- ❌ IEventRepository interface
-- ❌ IndexedDBEventRepository wrapper
-
-**Simplified:** 2 fewer abstraction layers, 250+ lines removed
-
----
-
-## Critical Mistakes Caught By Code Review
-
-This session involved **8 major architectural mistakes** that were caught and corrected through experienced code review:
-
-### Mistake #1: Incomplete RawEventData Interface
-**What I Did:** Created MockEventRepository with incomplete interface missing critical booking fields.
-**User Feedback:** *"This is unacceptable - you've missed essential fields."*
-**Impact If Uncaught:** Type safety violation, runtime errors, booking architecture broken.
-**Correction:** Added bookingId, resourceId, customerId, description fields with proper validation.
-
-### Mistake #2: Individual Service Injections in DataSeeder
-**What I Did:** Constructor with 8 separate service/repository parameters.
-**User Feedback:** *"Why not use arrays of IEntityService?"*
-**Impact If Uncaught:** Non-scalable design, violates Open/Closed Principle.
-**Correction:** Changed to polymorphic array injection with runtime entityType matching.
-
-### Mistake #3: Wrong IndexedDBService Responsibilities
-**What I Did:** Kept queue/sync operations in IndexedDBService after identifying connection management role.
-**User Feedback:** *"Queue and sync should move to OperationQueue."*
-**Impact If Uncaught:** Single Responsibility Principle violation, poor separation of concerns.
-**Correction:** Split into IndexedDBContext (connection) and OperationQueue (queue/sync).
-
-### Mistake #4: Direct IDBDatabase Injection
-**What I Did:** Kept `constructor(db: IDBDatabase)` pattern despite timing issues.
-**User Feedback:** *"Services are instantiated before database is ready."*
-**Impact If Uncaught:** Null reference errors, initialization failures.
-**Correction:** Changed to `constructor(context: IndexedDBContext)` with lazy getter.
-
-### Mistake #5: Attempted to Move Repository Methods to Service
-**What I Did:** Suggested moving createEvent/updateEvent from repository TO EventService.
-**User Feedback:** *"This makes no sense. BaseEntityService already has save(). Just DELETE them."*
-**Impact If Uncaught:** Redundant abstraction, unnecessary code, confusion about responsibilities.
-**Correction:** Deleted entire repository layer, use BaseEntityService methods directly.
-
-### Mistake #6: Misunderstood Repository Elimination Scope
-**What I Did:** Initially thought only createEvent/updateEvent should be removed.
-**User Feedback:** *"deleteEvent should also be deleted - we use service.delete() directly."*
-**Impact If Uncaught:** Partial refactoring, inconsistent patterns, remaining dead code.
-**Correction:** Eliminated IEventRepository and IndexedDBEventRepository entirely.
-
-### Mistake #7: Wrong DI Registration Strategy
-**What I Did:** Suggested registering EventService twice (as interface AND concrete type).
-**User Feedback:** *"Don't you understand generic interfaces? Just inject interface and typecast."*
-**Impact If Uncaught:** Unnecessary complexity, DI container pollution, confusion.
-**Correction:** Inject `IEntityService`, typecast to `EventService` in assignment.
-
-### Mistake #8: Implicit Downcast Assumption
-**What I Did:** Assumed TypeScript would allow implicit cast from interface to concrete type.
-**User Feedback:** *"Does TypeScript support implicit downcasts like C#?"*
-**Impact If Uncaught:** Compilation error, blocked deployment.
-**Correction:** Added explicit `as EventService` cast in constructor assignment.
-
----
-
-## Lessons Learned
-
-### 1. Interface Definitions Must Match Reality
-Creating interfaces without verifying actual data structure leads to type safety violations.
-**Solution:** Always validate interface against real data (JSON files, API responses, database schemas).
-
-### 2. Polymorphic Design Requires Array Thinking
-Individual injections (service1, service2, service3) don't scale.
-**Solution:** Inject arrays (`IEntityService[]`) with runtime matching by property (entityType).
-
-### 3. Single Responsibility Requires Honest Naming
-"IndexedDBService" doing 3 things (connection, queue, sync) violates SRP.
-**Solution:** Rename based on primary responsibility (IndexedDBContext), move other concerns elsewhere.
-
-### 4. Dependency Injection Timing Matters
-Services instantiated before dependencies are ready causes null reference issues.
-**Solution:** Inject context/provider, use lazy getters for actual resources.
-
-### 5. Abstraction Layers Should Add Value
-Repository wrapping service with no additional logic is pure overhead.
-**Solution:** Eliminate wrapper if it's just delegation. Use service directly.
-
-### 6. Generic Interfaces Enable Polymorphic Injection
-`IEntityService` can be resolved by DI, then cast to `EventService`.
-**Solution:** Inject interface type (DI understands), cast to concrete (code uses).
-
-### 7. TypeScript Type System Differs From C#
-No implicit downcast from interface to concrete type.
-**Solution:** Use explicit `as ConcreteType` casts when needed.
-
-### 8. Code Review Prevents Architectural Debt
-8 major mistakes in one session - without review, codebase would be severely compromised.
-**Solution:** **MANDATORY** experienced code review for architectural changes.
-
----
-
-## The Critical Importance of Experienced Code Review
-
-### Session Statistics
-- **Duration:** ~6 hours
-- **Major Mistakes:** 8
-- **Architectural Decisions:** 5
-- **Course Corrections:** 8
-- **Files Deleted:** 2 (would have been kept without review)
-- **Abstraction Layers Removed:** 2 (would have been added without review)
-
-### What Would Have Happened Without Review
-
-**If Mistake #1 (Incomplete Interface) Went Unnoticed:**
-- Runtime crashes when accessing bookingId/resourceId
-- Hours of debugging mysterious undefined errors
-- Potential data corruption in IndexedDB
-
-**If Mistake #5 (Moving Methods to Service) Was Implemented:**
-- EventService would have redundant createEvent/updateEvent
-- BaseEntityService.save() would be ignored
-- Duplicate business logic in multiple places
-- Confusion about which method to call
-
-**If Mistake #3 (Wrong Responsibilities) Was Accepted:**
-- IndexedDBService would continue violating SRP
-- Poor separation of concerns
-- Hard to test, hard to maintain
-- Future refactoring even more complex
-
-**If Mistake #7 (Double Registration) Was Used:**
-- DI container complexity
-- Potential singleton violations
-- Unclear which binding to use
-- Maintenance nightmare
-
-### The Pattern
-
-Every mistake followed the same trajectory:
-1. **I proposed a solution** (seemed reasonable to me)
-2. **User challenged the approach** (identified fundamental flaw)
-3. **I defended or misunderstood** (tried to justify)
-4. **User explained the principle** (taught correct pattern)
-5. **I implemented correctly** (architecture preserved)
-
-Without step 2-4, ALL 8 mistakes would have been committed to codebase.
-
-### Why This Matters
-
-This isn't about knowing specific APIs or syntax.
-This is about **architectural thinking** that takes years to develop:
-
-- Understanding when abstraction adds vs removes value
-- Recognizing single responsibility violations
-- Knowing when to delete code vs move code
-- Seeing polymorphic opportunities
-- Understanding dependency injection patterns
-- Recognizing premature optimization
-- Balancing DRY with over-abstraction
-
-**Conclusion:** Architectural changes require experienced oversight. The cost of mistakes compounds exponentially. One wrong abstraction leads to years of technical debt.
-
----
-
-## Current State & Next Steps
-
-### ✅ Build Status: Successful
-```
-[NovaDI] Performance Summary:
- - Program creation: 591.22ms
- - Files in TypeScript Program: 77
- - Files actually transformed: 56
- - Total: 1385.49ms
-```
-
-### ✅ Architecture State
-- **IndexedDBContext:** Connection provider only (clean responsibility)
-- **OperationQueue:** Queue + sync state operations (consolidated)
-- **BaseEntityService:** Lazy IDBDatabase access via getter (timing fixed)
-- **EventService:** Direct usage via IEntityService injection (no wrapper)
-- **DataSeeder:** Polymorphic array-based seeding (scales to any entity)
-- **Mock Repositories:** 4 entities loadable from JSON (development ready)
-
-### ✅ Data Flow Verified
-```
-App Initialization:
- 1. IndexedDBContext.initialize() → Database ready
- 2. DataSeeder.seedIfEmpty() → Loads mock data if empty
- 3. CalendarManager.initialize() → Starts calendar with data
-
-Event CRUD:
- EventManager → EventService → BaseEntityService → IndexedDBContext → IDBDatabase
-```
-
-### 🎯 EventService Pattern Established
-- Direct service usage (no repository wrapper)
-- Interface injection with typecast
-- Generic CRUD via BaseEntityService
-- Event-specific methods in EventService
-- Ready to replicate for Booking/Customer/Resource
-
-### 📋 Next Steps
-
-**Immediate:**
-1. Test calendar initialization with seeded data
-2. Verify event CRUD operations work
-3. Confirm no runtime errors from refactoring
-
-**Future (Not Part of This Session):**
-1. Apply same pattern to BookingManager (if needed)
-2. Implement queue logic (when sync required)
-3. Add pull sync (remote changes → IndexedDB)
-4. Implement delta sync (timestamps + fetchChanges)
-
----
-
-## Conclusion
-
-**Initial Goal:** Create Mock repositories and implement data seeding
-**Actual Work:** Complete repository elimination + IndexedDB architecture refactoring
-**Time:** ~6 hours
-**Mistakes Prevented:** 8 major architectural errors
-
-**Key Achievements:**
-- ✅ Cleaner architecture (2 fewer abstraction layers)
-- ✅ Better separation of concerns (IndexedDBContext, OperationQueue)
-- ✅ Fixed timing issues (lazy database access)
-- ✅ Polymorphic DataSeeder (scales to any entity)
-- ✅ Direct service usage pattern (no unnecessary wrappers)
-- ✅ 250+ lines of redundant code removed
-
-**Critical Lesson:**
-Without experienced code review, this session would have resulted in:
-- Broken type safety (Mistake #1)
-- Non-scalable design (Mistake #2)
-- Violated SRP (Mistake #3)
-- Timing bugs (Mistake #4)
-- Redundant abstraction (Mistakes #5, #6)
-- DI complexity (Mistakes #7, #8)
-
-**Architectural changes require mandatory senior-level oversight.** The patterns and principles that prevented these mistakes are not obvious and take years of experience to internalize.
-
----
-
-**Session Complete:** 2025-11-20
-**Documentation Quality:** High (detailed architectural decisions, mistake analysis, lessons learned)
-**Ready for:** Pattern replication to other entities (Booking, Customer, Resource)
diff --git a/coding-sessions/2025-11-22-audit-trail-event-driven-sync.md b/coding-sessions/2025-11-22-audit-trail-event-driven-sync.md
deleted file mode 100644
index f6cc609..0000000
--- a/coding-sessions/2025-11-22-audit-trail-event-driven-sync.md
+++ /dev/null
@@ -1,531 +0,0 @@
-# Audit Trail & Event-Driven Sync Architecture
-
-**Date:** 2025-11-22
-**Duration:** ~4 hours
-**Initial Scope:** Understand existing sync logic
-**Actual Scope:** Complete audit-based sync architecture with event-driven design
-
----
-
-## Executive Summary
-
-Discovered that existing sync infrastructure (SyncManager, SyncPlugin, OperationQueue) was completely disconnected - nothing was wired together. Redesigned from scratch using audit-based architecture where all entity changes are logged to an audit store, and SyncManager listens for AUDIT_LOGGED events to sync to backend.
-
-**Key Achievements:**
-- ✅ Designed event-driven audit trail architecture
-- ✅ Created AuditTypes, AuditStore, AuditService
-- ✅ Updated BaseEntityService with JSON diff calculation (json-diff-ts)
-- ✅ Implemented event emission on save/delete (ENTITY_SAVED, ENTITY_DELETED)
-- ✅ Refactored SyncManager to listen for AUDIT_LOGGED events
-- ✅ Fixed EventBus injection (required, not optional)
-- ✅ Added typed Payload interfaces for all events
-
-**Critical Discovery:** The "working" sync infrastructure was actually dead code - SyncManager was commented out, queue was never populated, and no events were being emitted.
-
----
-
-## Context: Starting Point
-
-### Previous Work (Nov 20, 2025)
-Repository Layer Elimination session established:
-- BaseEntityService with generic CRUD operations
-- IndexedDBContext for database connection
-- Direct service usage pattern (no repository wrapper)
-- DataSeeder for initial data loading
-
-### The Gap
-After repository elimination, we had:
-- ✅ Services working (BaseEntityService + SyncPlugin)
-- ✅ IndexedDB storing data
-- ❌ SyncManager commented out
-- ❌ OperationQueue never populated
-- ❌ No events emitted on entity changes
-- ❌ No audit trail for compliance
-
----
-
-## Session Evolution: Major Architectural Decisions
-
-### Phase 1: Discovery - Nothing Was Connected 🚨
-
-**User Question:** *"What synchronization logic do we have for the server database?"*
-
-**Investigation Findings:**
-- SyncManager exists but was commented out in index.ts
-- OperationQueue exists but never receives operations
-- BaseEntityService.save() just saves - no events, no queue
-- SyncPlugin provides sync status methods but nothing triggers them
-
-**The Truth:** Entire sync infrastructure was scaffolding with no actual wiring.
-
----
-
-### Phase 2: Architecture Discussion - Queue vs Audit
-
-**User's Initial Mental Model:**
-```
-IEntityService.save() → saves to IndexedDB → emits event
-SyncManager listens → reads pending from IndexedDB → syncs to backend
-```
-
-**Problem Identified:** "Who fills the queue?"
-
-**Options Discussed:**
-1. Service writes to queue
-2. EventManager writes to queue
-3. SyncManager reads pending from IndexedDB
-
-**User Insight:** *"I need an audit trail for all changes."*
-
-**Decision:** Drop OperationQueue concept, use Audit store instead.
-
----
-
-### Phase 3: Audit Architecture Design ✅
-
-**Requirements:**
-- All entity changes must be logged (compliance)
-- Changes should store JSON diff (not full entity)
-- userId required (hardcoded GUID for now)
-- Audit entries never deleted
-
-**Designed Event Chain:**
-```
-Entity change
- → BaseEntityService.save()
- → emit ENTITY_SAVED (with diff)
- → AuditService listens
- → creates audit entry
- → emit AUDIT_LOGGED
- → SyncManager listens
- → syncs to backend
-```
-
-**Why Chained Events?**
-User caught race condition: *"If both AuditService and SyncManager listen to ENTITY_SAVED, SyncManager could execute before the audit entry is created."*
-
-Solution: AuditService emits AUDIT_LOGGED after saving, SyncManager only listens to AUDIT_LOGGED.
-
----
-
-### Phase 4: Implementation - AuditTypes & AuditStore ✅
-
-**Created src/types/AuditTypes.ts:**
-```typescript
-export interface IAuditEntry extends ISync {
- id: string;
- entityType: EntityType;
- entityId: string;
- operation: 'create' | 'update' | 'delete';
- userId: string;
- timestamp: number;
- changes: any; // JSON diff result
- synced: boolean;
- syncStatus: 'synced' | 'pending' | 'error';
-}
-```
-
-**Created src/storage/audit/AuditStore.ts:**
-```typescript
-export class AuditStore implements IStore {
- readonly storeName = 'audit';
-
- create(db: IDBDatabase): void {
- const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
- store.createIndex('syncStatus', 'syncStatus', { unique: false });
- store.createIndex('synced', 'synced', { unique: false });
- store.createIndex('entityId', 'entityId', { unique: false });
- store.createIndex('timestamp', 'timestamp', { unique: false });
- }
-}
-```
-
----
-
-### Phase 5: BaseEntityService - Diff Calculation & Event Emission ✅
-
-**Added json-diff-ts dependency:**
-```bash
-npm install json-diff-ts
-```
-
-**Updated save() method:**
-```typescript
-async save(entity: T): Promise {
- const entityId = (entity as any).id;
-
- // Check if entity exists to determine create vs update
- const existingEntity = await this.get(entityId);
- const isCreate = existingEntity === null;
-
- // Calculate changes: full entity for create, diff for update
- let changes: any;
- if (isCreate) {
- changes = entity;
- } else {
- const existingSerialized = this.serialize(existingEntity);
- const newSerialized = this.serialize(entity);
- changes = diff(existingSerialized, newSerialized);
- }
-
- // ... save to IndexedDB ...
-
- // Emit ENTITY_SAVED event
- const payload: IEntitySavedPayload = {
- entityType: this.entityType,
- entityId,
- operation: isCreate ? 'create' : 'update',
- changes,
- timestamp: Date.now()
- };
- this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
-}
-```
-
----
-
-### Phase 6: AuditService - Override Pattern ✅
-
-**Key Design Decision:** AuditService overrides save() to NOT emit ENTITY_SAVED.
-
-**Why:** If AuditService.save() emitted ENTITY_SAVED, it would trigger AuditService again → infinite loop.
-
-**Created src/storage/audit/AuditService.ts:**
-```typescript
-export class AuditService extends BaseEntityService {
- readonly storeName = 'audit';
- readonly entityType: EntityType = 'Audit';
-
- constructor(context: IndexedDBContext, eventBus: IEventBus) {
- super(context, eventBus);
- this.setupEventListeners();
- }
-
- private setupEventListeners(): void {
- this.eventBus.on(CoreEvents.ENTITY_SAVED, (event: Event) => {
- const detail = (event as CustomEvent).detail;
- this.handleEntitySaved(detail);
- });
-
- this.eventBus.on(CoreEvents.ENTITY_DELETED, (event: Event) => {
- const detail = (event as CustomEvent).detail;
- this.handleEntityDeleted(detail);
- });
- }
-
- // Override save to emit AUDIT_LOGGED instead of ENTITY_SAVED
- async save(entity: IAuditEntry): Promise {
- // ... save to IndexedDB ...
-
- // Emit AUDIT_LOGGED (not ENTITY_SAVED)
- const payload: IAuditLoggedPayload = {
- auditId: entity.id,
- entityType: entity.entityType,
- entityId: entity.entityId,
- operation: entity.operation,
- timestamp: entity.timestamp
- };
- this.eventBus.emit(CoreEvents.AUDIT_LOGGED, payload);
- }
-
- // Audit entries cannot be deleted (compliance)
- async delete(_id: string): Promise {
- throw new Error('Audit entries cannot be deleted (compliance requirement)');
- }
-}
-```
-
----
-
-### Phase 7: SyncManager Refactoring ✅
-
-**Before:** Used OperationQueue (never populated)
-**After:** Listens to AUDIT_LOGGED, syncs audit entries
-
-**Key Changes:**
-```typescript
-export class SyncManager {
- constructor(
- eventBus: IEventBus,
- auditService: AuditService,
- auditApiRepository: IApiRepository
- ) {
- this.setupAuditListener();
- this.startSync();
- }
-
- private setupAuditListener(): void {
- this.eventBus.on(CoreEvents.AUDIT_LOGGED, () => {
- if (this.isOnline && !this.isSyncing) {
- this.processPendingAudits();
- }
- });
- }
-
- private async processPendingAudits(): Promise {
- const pendingAudits = await this.auditService.getPendingAudits();
- for (const audit of pendingAudits) {
- await this.auditApiRepository.sendCreate(audit);
- await this.auditService.markAsSynced(audit.id);
- }
- }
-}
-```
-
----
-
-### Phase 8: EventBus Injection Problem 🐛
-
-**Discovery:** Entity services had no EventBus!
-
-**User Observation:** *"There's no EventBus being passed. Why are you using super(...arguments) when there's an empty constructor?"*
-
-**Problem:**
-- BaseEntityService had optional eventBus parameter
-- Entity services (EventService, BookingService, etc.) had no constructors
-- EventBus was never passed → events never emitted
-
-**User Directive:** *"Remove the null check you added in BaseEntityService - it doesn't make sense."*
-
-**Fix:**
-1. Made eventBus required in BaseEntityService
-2. Added constructors to all entity services:
-
-```typescript
-// EventService.ts
-constructor(context: IndexedDBContext, eventBus: IEventBus) {
- super(context, eventBus);
-}
-
-// BookingService.ts, CustomerService.ts, ResourceService.ts - same pattern
-```
-
----
-
-### Phase 9: Typed Payload Interfaces ✅
-
-**User Observation:** *"The events you've created use anonymous types. I'd prefer typed interfaces following the existing Payload suffix convention."*
-
-**Added to src/types/EventTypes.ts:**
-```typescript
-export interface IEntitySavedPayload {
- entityType: EntityType;
- entityId: string;
- operation: 'create' | 'update';
- changes: any;
- timestamp: number;
-}
-
-export interface IEntityDeletedPayload {
- entityType: EntityType;
- entityId: string;
- operation: 'delete';
- timestamp: number;
-}
-
-export interface IAuditLoggedPayload {
- auditId: string;
- entityType: EntityType;
- entityId: string;
- operation: 'create' | 'update' | 'delete';
- timestamp: number;
-}
-```
-
----
-
-## Files Changed Summary
-
-### Files Created (4)
-1. **src/types/AuditTypes.ts** - IAuditEntry interface
-2. **src/storage/audit/AuditStore.ts** - IndexedDB store for audit entries
-3. **src/storage/audit/AuditService.ts** - Event-driven audit service
-4. **src/repositories/MockAuditRepository.ts** - Mock API for audit sync
-
-### Files Deleted (1)
-5. **src/storage/OperationQueue.ts** - Replaced by audit-based approach
-
-### Files Modified (9)
-6. **src/types/CalendarTypes.ts** - Added 'Audit' to EntityType
-7. **src/constants/CoreEvents.ts** - Added ENTITY_SAVED, ENTITY_DELETED, AUDIT_LOGGED
-8. **src/types/EventTypes.ts** - Added IEntitySavedPayload, IEntityDeletedPayload, IAuditLoggedPayload
-9. **src/storage/BaseEntityService.ts** - Added diff calculation, event emission, required eventBus
-10. **src/storage/events/EventService.ts** - Added constructor with eventBus
-11. **src/storage/bookings/BookingService.ts** - Added constructor with eventBus
-12. **src/storage/customers/CustomerService.ts** - Added constructor with eventBus
-13. **src/storage/resources/ResourceService.ts** - Added constructor with eventBus
-14. **src/workers/SyncManager.ts** - Refactored to use AuditService
-15. **src/storage/IndexedDBContext.ts** - Bumped DB version to 3
-16. **src/index.ts** - Updated DI registrations
-
----
-
-## Architecture Evolution Diagram
-
-**BEFORE (Disconnected):**
-```
-EventManager
- ↓
-EventService.save()
- ↓
-IndexedDB (saved)
-
-[OperationQueue - never filled]
-[SyncManager - commented out]
-```
-
-**AFTER (Event-Driven):**
-```
-EventManager
- ↓
-EventService.save()
- ↓
-BaseEntityService.save()
- ├── IndexedDB (saved)
- └── emit ENTITY_SAVED (with diff)
- ↓
- AuditService listens
- ├── Creates audit entry
- └── emit AUDIT_LOGGED
- ↓
- SyncManager listens
- └── Syncs to backend
-```
-
----
-
-## Key Design Decisions
-
-### 1. Audit-Based Instead of Queue-Based
-**Why:** Audit serves dual purpose - compliance trail AND sync source.
-**Benefit:** Single source of truth for all changes.
-
-### 2. Chained Events (ENTITY_SAVED → AUDIT_LOGGED)
-**Why:** Prevents race condition where SyncManager runs before audit is saved.
-**Benefit:** Guaranteed order of operations.
-
-### 3. JSON Diff for Changes
-**Why:** Only store what changed, not full entity.
-**Benefit:** Smaller audit entries, easier to see what changed.
-
-### 4. Override Pattern for AuditService
-**Why:** Prevent infinite loop (audit → event → audit → event...).
-**Benefit:** Clean separation without special flags.
-
-### 5. Required EventBus (Not Optional)
-**Why:** Events are core to architecture, not optional.
-**Benefit:** No null checks, guaranteed behavior.
-
----
-
-## Discussion Topics (Not Implemented)
-
-### IndexedDB for Logging/Traces
-User observation: *"This IndexedDB approach is quite interesting - we could extend it to handle logging, traces, and exceptions as well."*
-
-**Potential Extension:**
-- LogStore - Application logs
-- TraceStore - Performance traces
-- ExceptionStore - Caught/uncaught errors
-
-**Console Interception Pattern:**
-```typescript
-const originalLog = console.log;
-console.log = (...args) => {
- logService.save({ level: 'info', message: args, timestamp: Date.now() });
- if (isDevelopment) originalLog.apply(console, args);
-};
-```
-
-**Cleanup Strategy:**
-- 7-day retention
-- Or max 10,000 entries with FIFO
-
-**Decision:** Not implemented this session, but architecture supports it.
-
----
-
-## Lessons Learned
-
-### 1. Verify Existing Code Actually Works
-The sync infrastructure looked complete but was completely disconnected.
-**Lesson:** Don't assume existing code works - trace the actual flow.
-
-### 2. Audit Trail Serves Multiple Purposes
-Audit is not just for compliance - it's also the perfect sync source.
-**Lesson:** Look for dual-purpose designs.
-
-### 3. Event Ordering Matters
-Race conditions between listeners are real.
-**Lesson:** Use chained events when order matters.
-
-### 4. Optional Dependencies Create Hidden Bugs
-Optional eventBus meant events silently didn't fire.
-**Lesson:** Make core dependencies required.
-
-### 5. Type Consistency Matters
-Anonymous types in events vs Payload interfaces elsewhere.
-**Lesson:** Follow existing patterns in codebase.
-
----
-
-## Current State & Next Steps
-
-### ✅ Build Status: Successful
-```
-[NovaDI] Performance Summary:
- - Files in TypeScript Program: 80
- - Files actually transformed: 58
- - Total: 1467.93ms
-```
-
-### ✅ Architecture State
-- **Event-driven audit trail:** Complete
-- **AuditService:** Listens for entity events, creates audit entries
-- **SyncManager:** Listens for AUDIT_LOGGED, syncs to backend
-- **BaseEntityService:** Emits events on save/delete with JSON diff
-
-### ⚠️ Not Yet Tested
-- Runtime behavior (does AuditService receive events?)
-- Diff calculation accuracy
-- SyncManager sync flow
-- IndexedDB version upgrade (v2 → v3)
-
-### 📋 Next Steps
-
-**Immediate:**
-1. Test in browser - verify events fire correctly
-2. Check IndexedDB for audit entries after save
-3. Verify SyncManager logs sync attempts
-
-**Future:**
-1. Real backend API for audit sync
-2. Logging/traces extension (console interception)
-3. Cleanup strategy for old audit entries
-
----
-
-## Conclusion
-
-**Initial Goal:** Understand sync logic
-**Actual Work:** Complete architecture redesign
-
-**What We Found:**
-- Sync infrastructure was dead code
-- OperationQueue never populated
-- SyncManager commented out
-- No events being emitted
-
-**What We Built:**
-- Audit-based sync architecture
-- Event-driven design with chained events
-- JSON diff for change tracking
-- Typed payload interfaces
-
-**Key Insight:** Sometimes "understanding existing code" reveals there's nothing to understand - just scaffolding that needs to be replaced with actual implementation.
-
----
-
-**Session Complete:** 2025-11-22
-**Documentation Quality:** High
-**Ready for:** Runtime testing
diff --git a/docs/mock-repository-implementation-status.md b/docs/mock-repository-implementation-status.md
deleted file mode 100644
index bab7764..0000000
--- a/docs/mock-repository-implementation-status.md
+++ /dev/null
@@ -1,737 +0,0 @@
-# Mock Data Repository Implementation - Status Documentation
-
-**Document Generated:** 2025-11-19
-**Analysis Scope:** Mock Repository Implementation vs Target Architecture
-**Files Analyzed:** 4 repositories, 4 type files, 2 architecture docs
-
-## Executive Summary
-
-This document compares the current Mock Repository implementation against the documented target architecture. The analysis covers 4 entity types: Event, Booking, Customer, and Resource.
-
-**Overall Status:** Implementation is structurally correct but Event entity is missing critical fields required for the booking architecture.
-
-**Compliance Score:** 84%
-
----
-
-## 1. Event Entity Comparison
-
-### Current RawEventData Interface
-**Location:** `src/repositories/MockEventRepository.ts`
-
-```typescript
-interface RawEventData {
- id: string;
- title: string;
- start: string | Date;
- end: string | Date;
- type: string;
- color?: string;
- allDay?: boolean;
- [key: string]: unknown;
-}
-```
-
-### Target ICalendarEvent Interface
-**Location:** `src/types/CalendarTypes.ts`
-
-```typescript
-export interface ICalendarEvent extends ISync {
- id: string;
- title: string;
- description?: string;
- start: Date;
- end: Date;
- type: CalendarEventType;
- allDay: boolean;
-
- bookingId?: string;
- resourceId?: string;
- customerId?: string;
-
- recurringId?: string;
- metadata?: Record;
-}
-```
-
-### Documented JSON Format
-**Source:** `docs/mock-data-migration-guide.md`, `docs/booking-event-architecture.md`
-
-```json
-{
- "id": "EVT001",
- "title": "Balayage langt hår",
- "start": "2025-08-05T10:00:00",
- "end": "2025-08-05T11:00:00",
- "type": "customer",
- "allDay": false,
- "syncStatus": "synced",
- "bookingId": "BOOK001",
- "resourceId": "EMP001",
- "customerId": "CUST001",
- "metadata": { "duration": 60 }
-}
-```
-
-### Field-by-Field Comparison - Event Entity
-
-| Field | Current RawData | Target Interface | Documented JSON | Status |
-|-------|----------------|------------------|----------------|--------|
-| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `title` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `description` | ❌ Missing | ✅ `string?` | ❌ Missing | **MISSING** |
-| `start` | ✅ `string \| Date` | ✅ `Date` | ✅ Present | **MATCH** |
-| `end` | ✅ `string \| Date` | ✅ `Date` | ✅ Present | **MATCH** |
-| `type` | ✅ `string` | ✅ `CalendarEventType` | ✅ `"customer"` | **MATCH** (needs cast) |
-| `allDay` | ✅ `boolean?` | ✅ `boolean` | ✅ `false` | **MATCH** |
-| `bookingId` | ❌ Missing | ✅ `string?` | ✅ Present | **CRITICAL MISSING** |
-| `resourceId` | ❌ Missing | ✅ `string?` | ✅ Present | **CRITICAL MISSING** |
-| `customerId` | ❌ Missing | ✅ `string?` | ✅ Present | **CRITICAL MISSING** |
-| `recurringId` | ❌ Missing | ✅ `string?` | ❌ Not in example | **MISSING** |
-| `metadata` | ✅ Via `[key: string]` | ✅ `Record?` | ✅ Present | **MATCH** |
-| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ✅ `"synced"` | **MISSING (added in processing)** |
-| `color` | ✅ `string?` | ❌ Not in interface | ❌ Not documented | **EXTRA (legacy)** |
-
-### Critical Missing Fields - Event Entity
-
-#### 1. bookingId (CRITICAL)
-**Impact:** Cannot link customer events to booking data
-**Required For:**
-- Type `'customer'` events MUST have `bookingId`
-- Loading booking details when event is clicked
-- Cascading deletes (cancel booking → delete events)
-- Backend JOIN queries between CalendarEvent and Booking tables
-
-**Example:**
-```json
-{
- "id": "EVT001",
- "type": "customer",
- "bookingId": "BOOK001", // ← CRITICAL - Links to booking
- ...
-}
-```
-
-#### 2. resourceId (CRITICAL)
-**Impact:** Cannot filter events by resource (calendar columns)
-**Required For:**
-- Denormalized query performance (no JOIN needed)
-- Resource calendar views (week view with resource columns)
-- Resource utilization analytics
-- Quick filtering: "Show all events for EMP001"
-
-**Example:**
-```json
-{
- "id": "EVT001",
- "resourceId": "EMP001", // ← CRITICAL - Which stylist
- ...
-}
-```
-
-#### 3. customerId (CRITICAL)
-**Impact:** Cannot query customer events without loading booking
-**Required For:**
-- Denormalized query performance
-- Customer history views
-- Quick customer lookup: "Show all events for CUST001"
-- Analytics and reporting
-
-**Example:**
-```json
-{
- "id": "EVT001",
- "type": "customer",
- "customerId": "CUST001", // ← CRITICAL - Which customer
- ...
-}
-```
-
-#### 4. description (OPTIONAL)
-**Impact:** Cannot add detailed event notes
-**Required For:**
-- Event details panel
-- Additional context beyond title
-- Notes and instructions
-
-### Action Items for Events
-
-1. **Add to RawEventData:**
- - `description?: string`
- - `bookingId?: string` (CRITICAL)
- - `resourceId?: string` (CRITICAL)
- - `customerId?: string` (CRITICAL)
- - `recurringId?: string`
- - `metadata?: Record` (make explicit)
-
-2. **Update Processing:**
- - Explicitly map all new fields in `processCalendarData()`
- - Remove or document legacy `color` field
- - Ensure `allDay` defaults to `false` if missing
- - Validate that `type: 'customer'` events have `bookingId`
-
-3. **JSON File Requirements:**
- - Customer events MUST include `bookingId`, `resourceId`, `customerId`
- - Vacation/break/meeting events MUST NOT have `bookingId` or `customerId`
- - Vacation/break events SHOULD have `resourceId`
-
----
-
-## 2. Booking Entity Comparison
-
-### Current RawBookingData Interface
-**Location:** `src/repositories/MockBookingRepository.ts`
-
-```typescript
-interface RawBookingData {
- id: string;
- customerId: string;
- status: string;
- createdAt: string | Date;
- services: RawBookingService[];
- totalPrice?: number;
- tags?: string[];
- notes?: string;
- [key: string]: unknown;
-}
-
-interface RawBookingService {
- serviceId: string;
- serviceName: string;
- baseDuration: number;
- basePrice: number;
- customPrice?: number;
- resourceId: string;
-}
-```
-
-### Target IBooking Interface
-**Location:** `src/types/BookingTypes.ts`
-
-```typescript
-export interface IBooking extends ISync {
- id: string;
- customerId: string;
- status: BookingStatus;
- createdAt: Date;
- services: IBookingService[];
- totalPrice?: number;
- tags?: string[];
- notes?: string;
-}
-
-export interface IBookingService {
- serviceId: string;
- serviceName: string;
- baseDuration: number;
- basePrice: number;
- customPrice?: number;
- resourceId: string;
-}
-```
-
-### Documented JSON Format
-
-```json
-{
- "id": "BOOK001",
- "customerId": "CUST001",
- "status": "created",
- "createdAt": "2025-08-05T09:00:00",
- "services": [
- {
- "serviceId": "SRV001",
- "serviceName": "Balayage langt hår",
- "baseDuration": 60,
- "basePrice": 800,
- "customPrice": 800,
- "resourceId": "EMP001"
- }
- ],
- "totalPrice": 800,
- "notes": "Kunde ønsker lys blond"
-}
-```
-
-### Field-by-Field Comparison - Booking Entity
-
-**Main Booking:**
-
-| Field | Current RawData | Target Interface | Documented JSON | Status |
-|-------|----------------|------------------|----------------|--------|
-| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `customerId` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `status` | ✅ `string` | ✅ `BookingStatus` | ✅ `"created"` | **MATCH** (needs cast) |
-| `createdAt` | ✅ `string \| Date` | ✅ `Date` | ✅ Present | **MATCH** |
-| `services` | ✅ `RawBookingService[]` | ✅ `IBookingService[]` | ✅ Present | **MATCH** |
-| `totalPrice` | ✅ `number?` | ✅ `number?` | ✅ Present | **MATCH** |
-| `tags` | ✅ `string[]?` | ✅ `string[]?` | ❌ Not in example | **MATCH** |
-| `notes` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
-| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ❌ Not in example | **MISSING (added in processing)** |
-
-**BookingService:**
-
-| Field | Current RawData | Target Interface | Documented JSON | Status |
-|-------|----------------|------------------|----------------|--------|
-| `serviceId` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `serviceName` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `baseDuration` | ✅ `number` | ✅ `number` | ✅ Present | **MATCH** |
-| `basePrice` | ✅ `number` | ✅ `number` | ✅ Present | **MATCH** |
-| `customPrice` | ✅ `number?` | ✅ `number?` | ✅ Present | **MATCH** |
-| `resourceId` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-
-### Status - Booking Entity
-
-**RawBookingData: PERFECT MATCH** ✅
-**RawBookingService: PERFECT MATCH** ✅
-
-All fields present and correctly typed. `syncStatus` correctly added during processing.
-
-### Validation Recommendations
-
-- Ensure `customerId` is not null/empty (REQUIRED)
-- Ensure `services` array has at least one service (REQUIRED)
-- Validate `status` is valid BookingStatus enum value
-- Validate each service has `resourceId` (REQUIRED)
-
----
-
-## 3. Customer Entity Comparison
-
-### Current RawCustomerData Interface
-**Location:** `src/repositories/MockCustomerRepository.ts`
-
-```typescript
-interface RawCustomerData {
- id: string;
- name: string;
- phone: string;
- email?: string;
- metadata?: Record;
- [key: string]: unknown;
-}
-```
-
-### Target ICustomer Interface
-**Location:** `src/types/CustomerTypes.ts`
-
-```typescript
-export interface ICustomer extends ISync {
- id: string;
- name: string;
- phone: string;
- email?: string;
- metadata?: Record;
-}
-```
-
-### Documented JSON Format
-
-```json
-{
- "id": "CUST001",
- "name": "Maria Jensen",
- "phone": "+45 12 34 56 78",
- "email": "maria.jensen@example.com",
- "metadata": {
- "preferredStylist": "EMP001",
- "allergies": ["ammonia"]
- }
-}
-```
-
-### Field-by-Field Comparison - Customer Entity
-
-| Field | Current RawData | Target Interface | Documented JSON | Status |
-|-------|----------------|------------------|----------------|--------|
-| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `name` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `phone` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `email` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
-| `metadata` | ✅ `Record?` | ✅ `Record?` | ✅ Present | **MATCH** |
-| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ❌ Not in example | **MISSING (added in processing)** |
-
-### Status - Customer Entity
-
-**RawCustomerData: PERFECT MATCH** ✅
-
-All fields present and correctly typed. `syncStatus` correctly added during processing.
-
----
-
-## 4. Resource Entity Comparison
-
-### Current RawResourceData Interface
-**Location:** `src/repositories/MockResourceRepository.ts`
-
-```typescript
-interface RawResourceData {
- id: string;
- name: string;
- displayName: string;
- type: string;
- avatarUrl?: string;
- color?: string;
- isActive?: boolean;
- metadata?: Record;
- [key: string]: unknown;
-}
-```
-
-### Target IResource Interface
-**Location:** `src/types/ResourceTypes.ts`
-
-```typescript
-export interface IResource extends ISync {
- id: string;
- name: string;
- displayName: string;
- type: ResourceType;
- avatarUrl?: string;
- color?: string;
- isActive?: boolean;
- metadata?: Record;
-}
-```
-
-### Documented JSON Format
-
-```json
-{
- "id": "EMP001",
- "name": "karina.knudsen",
- "displayName": "Karina Knudsen",
- "type": "person",
- "avatarUrl": "/avatars/karina.jpg",
- "color": "#9c27b0",
- "isActive": true,
- "metadata": {
- "role": "master stylist",
- "specialties": ["balayage", "color", "bridal"]
- }
-}
-```
-
-### Field-by-Field Comparison - Resource Entity
-
-| Field | Current RawData | Target Interface | Documented JSON | Status |
-|-------|----------------|------------------|----------------|--------|
-| `id` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `name` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `displayName` | ✅ `string` | ✅ `string` | ✅ Present | **MATCH** |
-| `type` | ✅ `string` | ✅ `ResourceType` | ✅ `"person"` | **MATCH** (needs cast) |
-| `avatarUrl` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
-| `color` | ✅ `string?` | ✅ `string?` | ✅ Present | **MATCH** |
-| `isActive` | ✅ `boolean?` | ✅ `boolean?` | ✅ Present | **MATCH** |
-| `metadata` | ✅ `Record?` | ✅ `Record?` | ✅ Present | **MATCH** |
-| `syncStatus` | ❌ Missing | ✅ `SyncStatus` (via ISync) | ❌ Not in example | **MISSING (added in processing)** |
-
-### Status - Resource Entity
-
-**RawResourceData: PERFECT MATCH** ✅
-
-All fields present and correctly typed. `syncStatus` correctly added during processing.
-
-### Validation Recommendations
-
-- Validate `type` is valid ResourceType enum value (`'person' | 'room' | 'equipment' | 'vehicle' | 'custom'`)
-
----
-
-## Summary Table
-
-| Entity | Core Fields Status | Missing Critical Fields | Extra Fields | Overall Status |
-|--------|-------------------|-------------------------|--------------|----------------|
-| **Event** | ✅ Basic fields OK | ❌ 4 critical fields missing | ⚠️ `color` (legacy) | **NEEDS UPDATES** |
-| **Booking** | ✅ All fields present | ✅ None | ✅ None | **COMPLETE ✅** |
-| **Customer** | ✅ All fields present | ✅ None | ✅ None | **COMPLETE ✅** |
-| **Resource** | ✅ All fields present | ✅ None | ✅ None | **COMPLETE ✅** |
-
----
-
-## Architecture Validation Rules
-
-### Event Type Constraints
-
-From `docs/booking-event-architecture.md`:
-
-```typescript
-// Rule 1: Customer events MUST have booking reference
-if (event.type === 'customer') {
- assert(event.bookingId !== undefined, "Customer events require bookingId");
- assert(event.customerId !== undefined, "Customer events require customerId");
- assert(event.resourceId !== undefined, "Customer events require resourceId");
-}
-
-// Rule 2: Non-customer events MUST NOT have booking reference
-if (event.type !== 'customer') {
- assert(event.bookingId === undefined, "Only customer events have bookingId");
- assert(event.customerId === undefined, "Only customer events have customerId");
-}
-
-// Rule 3: Vacation/break events MUST have resource assignment
-if (event.type === 'vacation' || event.type === 'break') {
- assert(event.resourceId !== undefined, "Vacation/break events require resourceId");
-}
-
-// Rule 4: Meeting/blocked events MAY have resource assignment
-if (event.type === 'meeting' || event.type === 'blocked') {
- // resourceId is optional
-}
-```
-
-### Booking Constraints
-
-```typescript
-// Rule 5: Booking ALWAYS has customer
-assert(booking.customerId !== "", "Booking requires customer");
-
-// Rule 6: Booking ALWAYS has services
-assert(booking.services.length > 0, "Booking requires at least one service");
-
-// Rule 7: Each service MUST have resource assignment
-booking.services.forEach(service => {
- assert(service.resourceId !== undefined, "Service requires resourceId");
-});
-
-// Rule 8: Service resourceId becomes Event resourceId
-// When a booking has ONE service:
-// event.resourceId = booking.services[0].resourceId
-// When a booking has MULTIPLE services:
-// ONE event per service, each with different resourceId
-```
-
-### Denormalization Rules
-
-From `docs/booking-event-architecture.md` (lines 532-547):
-
-**Backend performs JOIN and denormalizes:**
-
-```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 table
-FROM CalendarEvent e
-LEFT JOIN Booking b ON e.BookingId = b.Id
-WHERE e.Start >= @start AND e.Start <= @end
-```
-
-**Why denormalization:**
-- **Performance:** No JOIN needed in frontend queries
-- **Resource filtering:** Quick "show all events for EMP001"
-- **Customer filtering:** Quick "show all events for CUST001"
-- **Offline-first:** Complete event data available without JOIN
-
----
-
-## Recommended Implementation
-
-### Phase 1: Update RawEventData Interface (HIGH PRIORITY)
-
-**File:** `src/repositories/MockEventRepository.ts`
-
-```typescript
-interface RawEventData {
- // Core fields (required)
- id: string;
- title: string;
- start: string | Date;
- end: string | Date;
- type: string;
- allDay?: boolean;
-
- // Denormalized references (NEW - CRITICAL for booking architecture)
- bookingId?: string; // Reference to booking (customer events only)
- resourceId?: string; // Which resource owns this slot
- customerId?: string; // Customer reference (denormalized from booking)
-
- // Optional fields
- description?: string; // Detailed event notes
- recurringId?: string; // For recurring events
- metadata?: Record; // Flexible metadata
-
- // Legacy (deprecated, keep for backward compatibility)
- color?: string; // UI-specific field
-}
-```
-
-### Phase 2: Update processCalendarData() Method
-
-```typescript
-private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
- return data.map((event): ICalendarEvent => {
- // Validate event type constraints
- if (event.type === 'customer') {
- if (!event.bookingId) {
- console.warn(`Customer event ${event.id} missing bookingId`);
- }
- if (!event.resourceId) {
- console.warn(`Customer event ${event.id} missing resourceId`);
- }
- if (!event.customerId) {
- console.warn(`Customer event ${event.id} missing customerId`);
- }
- }
-
- return {
- id: event.id,
- title: event.title,
- description: event.description,
- start: new Date(event.start),
- end: new Date(event.end),
- type: event.type as CalendarEventType,
- allDay: event.allDay || false,
-
- // Denormalized references (CRITICAL)
- bookingId: event.bookingId,
- resourceId: event.resourceId,
- customerId: event.customerId,
-
- // Optional fields
- recurringId: event.recurringId,
- metadata: event.metadata,
-
- syncStatus: 'synced' as const
- };
- });
-}
-```
-
-### Phase 3: Testing (RECOMMENDED)
-
-1. **Test customer event with booking reference**
- - Verify `bookingId`, `resourceId`, `customerId` are preserved
- - Verify type is correctly cast to `CalendarEventType`
-
-2. **Test vacation event without booking**
- - Verify `bookingId` and `customerId` are `undefined`
- - Verify `resourceId` IS present (required for vacation/break)
-
-3. **Test split-resource booking scenario**
- - Booking with 2 services (different resources)
- - Should create 2 events with different `resourceId`
-
-4. **Test event-booking relationship queries**
- - Load event by `bookingId`
- - Load all events for `resourceId`
- - Load all events for `customerId`
-
----
-
-## Architecture Compliance Score
-
-| Aspect | Score | Notes |
-|--------|-------|-------|
-| Repository Pattern | 100% | Correctly implements IApiRepository |
-| Entity Interfaces | 75% | Booking/Customer/Resource perfect, Event missing 4 critical fields |
-| Data Processing | 90% | Correct date/type conversions, needs explicit field mapping |
-| Type Safety | 85% | Good type assertions, needs validation |
-| Documentation Alignment | 70% | Partially matches documented examples |
-| **Overall** | **84%** | **Good foundation, Event entity needs updates** |
-
----
-
-## Gap Analysis Summary
-
-### What's Working ✅
-
-- **Repository pattern:** Correctly implements IApiRepository interface
-- **Booking entity:** 100% correct (all fields match)
-- **Customer entity:** 100% correct (all fields match)
-- **Resource entity:** 100% correct (all fields match)
-- **Date processing:** string | Date → Date correctly handled
-- **Type assertions:** string → enum types correctly cast
-- **SyncStatus injection:** Correctly added during processing
-- **Error handling:** Unsupported operations (create/update/delete) throw errors
-- **fetchAll() implementation:** Correctly loads from JSON and processes data
-
-### What's Missing ❌
-
-**Event Entity - 4 Critical Fields:**
-
-1. **bookingId** - Cannot link events to bookings
- - Impact: Cannot load booking details when event is clicked
- - Impact: Cannot cascade delete when booking is cancelled
- - Impact: Cannot query events by booking
-
-2. **resourceId** - Cannot query by resource
- - Impact: Cannot filter calendar by resource (columns)
- - Impact: Cannot show resource utilization
- - Impact: Denormalization benefit lost (requires JOIN)
-
-3. **customerId** - Cannot query by customer
- - Impact: Cannot show customer history
- - Impact: Cannot filter events by customer
- - Impact: Denormalization benefit lost (requires JOIN)
-
-4. **description** - Cannot add detailed notes
- - Impact: Limited event details
- - Impact: No additional context beyond title
-
-### What Needs Validation ⚠️
-
-- **Event type constraints:** Customer events require `bookingId`
-- **Booking constraints:** Must have `customerId` and `services[]`
-- **Resource assignment:** Vacation/break events require `resourceId`
-- **Enum validation:** Validate `type`, `status` match enum values
-
-### What Needs Cleanup 🧹
-
-- **Legacy `color` field:** Present in RawEventData but not in ICalendarEvent
-- **Index signature:** Consider removing `[key: string]: unknown` once all fields are explicit
-
----
-
-## Next Steps
-
-### Immediate (HIGH PRIORITY)
-
-1. **Update Event Entity**
- - Add 4 missing fields to `RawEventData`
- - Update `processCalendarData()` with explicit mapping
- - Add validation for type constraints
-
-### Short-term (MEDIUM PRIORITY)
-
-2. **Create Mock Data Files**
- - Update `wwwroot/data/mock-events.json` with denormalized fields
- - Ensure `mock-bookings.json`, `mock-customers.json`, `mock-resources.json` exist
- - Verify relationships (event.bookingId → booking.id)
-
-3. **Add Validation Layer**
- - Validate event-booking relationships
- - Validate required fields per event type
- - Log warnings for data integrity issues
-
-### Long-term (LOW PRIORITY)
-
-4. **Update Tests**
- - Test new fields in event processing
- - Test validation rules
- - Test cross-entity relationships
-
-5. **Documentation**
- - Update CLAUDE.md with Mock repository usage
- - Document validation rules
- - Document denormalization strategy
-
----
-
-## Conclusion
-
-The Mock Repository implementation has a **strong foundation** with 3 out of 4 entities (Booking, Customer, Resource) perfectly matching the target architecture.
-
-The **Event entity** needs critical updates to support the booking architecture's denormalization strategy. Adding the 4 missing fields (`bookingId`, `resourceId`, `customerId`, `description`) will bring the implementation to **100% compliance** with the documented architecture.
-
-**Estimated effort:** 1-2 hours for updates + testing
-
-**Risk:** Low - changes are additive (no breaking changes to existing code)
-
-**Priority:** HIGH - required for booking architecture to function correctly
diff --git a/package-lock.json b/package-lock.json
index 1389069..11fc31c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,8 +11,7 @@
"@novadi/core": "^0.6.0",
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
"dayjs": "^1.11.19",
- "fuse.js": "^7.1.0",
- "json-diff-ts": "^4.8.2"
+ "fuse.js": "^7.1.0"
},
"devDependencies": {
"@fullhuman/postcss-purgecss": "^7.0.2",
@@ -3098,12 +3097,6 @@
}
}
},
- "node_modules/json-diff-ts": {
- "version": "4.8.2",
- "resolved": "https://registry.npmjs.org/json-diff-ts/-/json-diff-ts-4.8.2.tgz",
- "integrity": "sha512-7LgOTnfK5XnBs0o0AtHTkry5QGZT7cSlAgu5GtiomUeoHqOavAUDcONNm/bCe4Lapt0AHnaidD5iSE+ItvxKkA==",
- "license": "MIT"
- },
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
diff --git a/package.json b/package.json
index d2aadc1..f42899e 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,6 @@
"@novadi/core": "^0.6.0",
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
"dayjs": "^1.11.19",
- "fuse.js": "^7.1.0",
- "json-diff-ts": "^4.8.2"
+ "fuse.js": "^7.1.0"
}
}
diff --git a/src/constants/CoreEvents.ts b/src/constants/CoreEvents.ts
index 983e121..52b285d 100644
--- a/src/constants/CoreEvents.ts
+++ b/src/constants/CoreEvents.ts
@@ -47,11 +47,6 @@ export const CoreEvents = {
SYNC_COMPLETED: 'sync:completed',
SYNC_FAILED: 'sync:failed',
SYNC_RETRY: 'sync:retry',
-
- // Entity events (3) - for audit and sync
- ENTITY_SAVED: 'entity:saved',
- ENTITY_DELETED: 'entity:deleted',
- AUDIT_LOGGED: 'audit:logged',
// Filter events (1)
FILTER_CHANGED: 'filter:changed',
diff --git a/src/datasources/DateColumnDataSource.ts b/src/datasources/DateColumnDataSource.ts
index a916e04..d4ac0dc 100644
--- a/src/datasources/DateColumnDataSource.ts
+++ b/src/datasources/DateColumnDataSource.ts
@@ -2,7 +2,6 @@ import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource';
import { DateService } from '../utils/DateService';
import { Configuration } from '../configurations/CalendarConfig';
import { CalendarView } from '../types/CalendarTypes';
-import { EventService } from '../storage/events/EventService';
/**
* DateColumnDataSource - Provides date-based columns
@@ -11,33 +10,27 @@ import { EventService } from '../storage/events/EventService';
* - Current date
* - Current view (day/week/month)
* - Workweek settings
- *
- * Also fetches and filters events per column using EventService.
*/
export class DateColumnDataSource implements IColumnDataSource {
private dateService: DateService;
private config: Configuration;
- private eventService: EventService;
private currentDate: Date;
private currentView: CalendarView;
constructor(
dateService: DateService,
- config: Configuration,
- eventService: EventService
+ config: Configuration
) {
this.dateService = dateService;
this.config = config;
- this.eventService = eventService;
this.currentDate = new Date();
this.currentView = this.config.currentView;
}
/**
- * Get columns (dates) to display with their events
- * Each column fetches its own events directly from EventService
+ * Get columns (dates) to display
*/
- public async getColumns(): Promise {
+ public getColumns(): IColumnInfo[] {
let dates: Date[];
switch (this.currentView) {
@@ -54,20 +47,11 @@ export class DateColumnDataSource implements IColumnDataSource {
dates = this.getWeekDates();
}
- // Fetch events for each column directly from EventService
- const columnsWithEvents = await Promise.all(
- dates.map(async date => ({
- identifier: this.dateService.formatISODate(date),
- data: date,
- events: await this.eventService.getByDateRange(
- this.dateService.startOfDay(date),
- this.dateService.endOfDay(date)
- ),
- groupId: 'week' // All columns in date mode share same group for spanning
- }))
- );
-
- return columnsWithEvents;
+ // Convert Date[] to IColumnInfo[]
+ return dates.map(date => ({
+ identifier: this.dateService.formatISODate(date),
+ data: date
+ }));
}
/**
@@ -77,13 +61,6 @@ export class DateColumnDataSource implements IColumnDataSource {
return 'date';
}
- /**
- * Check if this datasource is in resource mode
- */
- public isResource(): boolean {
- return false;
- }
-
/**
* Update current date
*/
@@ -91,13 +68,6 @@ export class DateColumnDataSource implements IColumnDataSource {
this.currentDate = date;
}
- /**
- * Get current date
- */
- public getCurrentDate(): Date {
- return this.currentDate;
- }
-
/**
* Update current view
*/
diff --git a/src/datasources/ResourceColumnDataSource.ts b/src/datasources/ResourceColumnDataSource.ts
deleted file mode 100644
index 6d1df45..0000000
--- a/src/datasources/ResourceColumnDataSource.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource';
-import { CalendarView } from '../types/CalendarTypes';
-import { ResourceService } from '../storage/resources/ResourceService';
-import { EventService } from '../storage/events/EventService';
-import { DateService } from '../utils/DateService';
-
-/**
- * ResourceColumnDataSource - Provides resource-based columns
- *
- * In resource mode, columns represent resources (people, rooms, etc.)
- * instead of dates. Events are filtered by current date AND resourceId.
- */
-export class ResourceColumnDataSource implements IColumnDataSource {
- private resourceService: ResourceService;
- private eventService: EventService;
- private dateService: DateService;
- private currentDate: Date;
- private currentView: CalendarView;
-
- constructor(
- resourceService: ResourceService,
- eventService: EventService,
- dateService: DateService
- ) {
- this.resourceService = resourceService;
- this.eventService = eventService;
- this.dateService = dateService;
- this.currentDate = new Date();
- this.currentView = 'day';
- }
-
- /**
- * Get columns (resources) to display with their events
- */
- public async getColumns(): Promise {
- const resources = await this.resourceService.getActive();
- const startDate = this.dateService.startOfDay(this.currentDate);
- const endDate = this.dateService.endOfDay(this.currentDate);
-
- // Fetch events for each resource in parallel
- const columnsWithEvents = await Promise.all(
- resources.map(async resource => ({
- identifier: resource.id,
- data: resource,
- events: await this.eventService.getByResourceAndDateRange(resource.id, startDate, endDate),
- groupId: resource.id // Each resource is its own group - no spanning across resources
- }))
- );
-
- return columnsWithEvents;
- }
-
- /**
- * Get type of datasource
- */
- public getType(): 'date' | 'resource' {
- return 'resource';
- }
-
- /**
- * Check if this datasource is in resource mode
- */
- public isResource(): boolean {
- return true;
- }
-
- /**
- * Update current date (for event filtering)
- */
- public setCurrentDate(date: Date): void {
- this.currentDate = date;
- }
-
- /**
- * Update current view
- */
- public setCurrentView(view: CalendarView): void {
- this.currentView = view;
- }
-
- /**
- * Get current date (for event filtering)
- */
- public getCurrentDate(): Date {
- return this.currentDate;
- }
-}
diff --git a/src/elements/SwpEventElement.ts b/src/elements/SwpEventElement.ts
index 3f28a70..4b90898 100644
--- a/src/elements/SwpEventElement.ts
+++ b/src/elements/SwpEventElement.ts
@@ -112,20 +112,19 @@ export class SwpEventElement extends BaseSwpEventElement {
/**
* Update event position during drag
- * Uses the event's existing date, only updates the time based on Y position
+ * @param columnDate - The date of the column
* @param snappedY - The Y position in pixels
*/
- public updatePosition(snappedY: number): void {
+ public updatePosition(columnDate: Date, snappedY: number): void {
// 1. Update visual position
this.style.top = `${snappedY + 1}px`;
- // 2. Calculate new timestamps (keep existing date, only change time)
- const existingDate = this.start;
+ // 2. Calculate new timestamps
const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY);
// 3. Update data attributes (triggers attributeChangedCallback)
- const startDate = this.dateService.createDateAtTime(existingDate, startMinutes);
- let endDate = this.dateService.createDateAtTime(existingDate, endMinutes);
+ const startDate = this.dateService.createDateAtTime(columnDate, startMinutes);
+ let endDate = this.dateService.createDateAtTime(columnDate, endMinutes);
// Handle cross-midnight events
if (endMinutes >= 1440) {
@@ -296,11 +295,6 @@ export class SwpEventElement extends BaseSwpEventElement {
element.dataset.type = event.type;
element.dataset.duration = event.metadata?.duration?.toString() || '60';
- // Apply color class from metadata
- if (event.metadata?.color) {
- element.classList.add(`is-${event.metadata.color}`);
- }
-
return element;
}
@@ -378,11 +372,6 @@ export class SwpAllDayEventElement extends BaseSwpEventElement {
element.dataset.allday = 'true';
element.textContent = event.title;
- // Apply color class from metadata
- if (event.metadata?.color) {
- element.classList.add(`is-${event.metadata.color}`);
- }
-
return element;
}
}
diff --git a/src/index.ts b/src/index.ts
index 5757592..cf8e7de 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,7 +4,7 @@ import { eventBus } from './core/EventBus';
import { ConfigManager } from './configurations/ConfigManager';
import { Configuration } from './configurations/CalendarConfig';
import { URLManager } from './utils/URLManager';
-import { ICalendarEvent, IEventBus } from './types/CalendarTypes';
+import { IEventBus } from './types/CalendarTypes';
// Import all managers
import { EventManager } from './managers/EventManager';
@@ -23,21 +23,17 @@ import { HeaderManager } from './managers/HeaderManager';
import { WorkweekPresets } from './components/WorkweekPresets';
// Import repositories and storage
+import { IEventRepository } from './repositories/IEventRepository';
import { MockEventRepository } from './repositories/MockEventRepository';
-import { MockBookingRepository } from './repositories/MockBookingRepository';
-import { MockCustomerRepository } from './repositories/MockCustomerRepository';
-import { MockResourceRepository } from './repositories/MockResourceRepository';
-import { MockAuditRepository } from './repositories/MockAuditRepository';
+import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository';
import { IApiRepository } from './repositories/IApiRepository';
-import { IAuditEntry } from './types/AuditTypes';
import { ApiEventRepository } from './repositories/ApiEventRepository';
import { ApiBookingRepository } from './repositories/ApiBookingRepository';
import { ApiCustomerRepository } from './repositories/ApiCustomerRepository';
import { ApiResourceRepository } from './repositories/ApiResourceRepository';
-import { IndexedDBContext } from './storage/IndexedDBContext';
+import { IndexedDBService } from './storage/IndexedDBService';
+import { OperationQueue } from './storage/OperationQueue';
import { IStore } from './storage/IStore';
-import { AuditStore } from './storage/audit/AuditStore';
-import { AuditService } from './storage/audit/AuditService';
import { BookingStore } from './storage/bookings/BookingStore';
import { CustomerStore } from './storage/customers/CustomerStore';
import { ResourceStore } from './storage/resources/ResourceStore';
@@ -50,7 +46,6 @@ import { ResourceService } from './storage/resources/ResourceService';
// Import workers
import { SyncManager } from './workers/SyncManager';
-import { DataSeeder } from './workers/DataSeeder';
// Import renderers
import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer';
@@ -70,12 +65,6 @@ import { EventStackManager } from './managers/EventStackManager';
import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator';
import { IColumnDataSource } from './types/ColumnDataSource';
import { DateColumnDataSource } from './datasources/DateColumnDataSource';
-import { ResourceColumnDataSource } from './datasources/ResourceColumnDataSource';
-import { ResourceHeaderRenderer } from './renderers/ResourceHeaderRenderer';
-import { ResourceColumnRenderer } from './renderers/ResourceColumnRenderer';
-import { IBooking } from './types/BookingTypes';
-import { ICustomer } from './types/CustomerTypes';
-import { IResource } from './types/ResourceTypes';
/**
* Handle deep linking functionality after managers are initialized
@@ -127,51 +116,36 @@ async function initializeCalendar(): Promise {
builder.registerType(CustomerStore).as();
builder.registerType(ResourceStore).as();
builder.registerType(EventStore).as();
- builder.registerType(AuditStore).as();
+
// Register storage and repository services
- builder.registerType(IndexedDBContext).as();
+ builder.registerType(IndexedDBService).as();
+ builder.registerType(OperationQueue).as();
- // Register Mock repositories (development/testing - load from JSON files)
- // Each entity type has its own Mock repository implementing IApiRepository
- builder.registerType(MockEventRepository).as>();
- builder.registerType(MockBookingRepository).as>();
- builder.registerType(MockCustomerRepository).as>();
- builder.registerType(MockResourceRepository).as>();
- builder.registerType(MockAuditRepository).as>();
+ // Register API repositories (backend sync)
+ // Each entity type has its own API repository implementing IApiRepository
+ builder.registerType(ApiEventRepository).as>();
+ builder.registerType(ApiBookingRepository).as>();
+ builder.registerType(ApiCustomerRepository).as>();
+ builder.registerType(ApiResourceRepository).as>();
-
- let calendarMode = 'resource' ;
- // Register DataSource and HeaderRenderer based on mode
- if (calendarMode === 'resource') {
- builder.registerType(ResourceColumnDataSource).as();
- builder.registerType(ResourceHeaderRenderer).as();
- } else {
- builder.registerType(DateColumnDataSource).as();
- builder.registerType(DateHeaderRenderer).as();
- }
-
- // Register entity services (sync status management)
+ builder.registerType(DateColumnDataSource).as();
+ // Register entity services (sync status management)
// Open/Closed Principle: Adding new entity only requires adding one line here
- builder.registerType(EventService).as>();
- builder.registerType(EventService).as();
- builder.registerType(BookingService).as>();
- builder.registerType(CustomerService).as>();
- builder.registerType(ResourceService).as>();
- builder.registerType(ResourceService).as();
- builder.registerType(AuditService).as();
+ builder.registerType(EventService).as>();
+ builder.registerType(BookingService).as>();
+ builder.registerType(CustomerService).as>();
+ builder.registerType(ResourceService).as>();
+
+ // Register IndexedDB repositories (offline-first)
+ builder.registerType(IndexedDBEventRepository).as();
// Register workers
builder.registerType(SyncManager).as();
- builder.registerType(DataSeeder).as();
// Register renderers
- // Note: IHeaderRenderer and IColumnRenderer are registered above based on calendarMode
- if (calendarMode === 'resource') {
- builder.registerType(ResourceColumnRenderer).as();
- } else {
- builder.registerType(DateColumnRenderer).as();
- }
+ builder.registerType(DateHeaderRenderer).as();
+ builder.registerType(DateColumnRenderer).as();
builder.registerType(DateEventRenderer).as();
// Register core services and utilities
@@ -207,13 +181,6 @@ async function initializeCalendar(): Promise {
// Build the container
const app = builder.build();
- // Initialize database and seed data BEFORE initializing managers
- const indexedDBContext = app.resolveType();
- await indexedDBContext.initialize();
-
- const dataSeeder = app.resolveType();
- await dataSeeder.seedIfEmpty();
-
// Get managers from container
const eb = app.resolveType();
const calendarManager = app.resolveType();
@@ -234,11 +201,12 @@ async function initializeCalendar(): Promise {
await calendarManager.initialize?.();
await resizeHandleManager.initialize?.();
- // Resolve AuditService (starts listening for entity events)
- const auditService = app.resolveType();
-
- // Resolve SyncManager (starts background sync automatically)
- const syncManager = app.resolveType();
+ // Resolve SyncManager (starts automatically in constructor)
+ // Resolve SyncManager (starts automatically in constructor)
+ // Resolve SyncManager (starts automatically in constructor)
+ // Resolve SyncManager (starts automatically in constructor)
+ // Resolve SyncManager (starts automatically in constructor)
+ //const syncManager = app.resolveType();
// Handle deep linking after managers are initialized
await handleDeepLinking(eventManager, urlManager);
@@ -251,8 +219,7 @@ async function initializeCalendar(): Promise {
calendarManager: typeof calendarManager;
eventManager: typeof eventManager;
workweekPresetsManager: typeof workweekPresetsManager;
- auditService: typeof auditService;
- syncManager: typeof syncManager;
+ //syncManager: typeof syncManager;
};
}).calendarDebug = {
eventBus,
@@ -260,8 +227,7 @@ async function initializeCalendar(): Promise {
calendarManager,
eventManager,
workweekPresetsManager,
- auditService,
- syncManager,
+ //syncManager,
};
} catch (error) {
diff --git a/src/managers/AllDayManager.ts b/src/managers/AllDayManager.ts
index 7f7c9ed..6cee9cf 100644
--- a/src/managers/AllDayManager.ts
+++ b/src/managers/AllDayManager.ts
@@ -5,7 +5,6 @@ import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine';
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
-import { IColumnDataSource } from '../types/ColumnDataSource';
import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
@@ -31,13 +30,12 @@ export class AllDayManager {
private allDayEventRenderer: AllDayEventRenderer;
private eventManager: EventManager;
private dateService: DateService;
- private dataSource: IColumnDataSource;
private layoutEngine: AllDayLayoutEngine | null = null;
// State tracking for layout calculation
private currentAllDayEvents: ICalendarEvent[] = [];
- private currentColumns: IColumnBounds[] = [];
+ private currentWeekDates: IColumnBounds[] = [];
// Expand/collapse state
private isExpanded: boolean = false;
@@ -47,13 +45,11 @@ export class AllDayManager {
constructor(
eventManager: EventManager,
allDayEventRenderer: AllDayEventRenderer,
- dateService: DateService,
- dataSource: IColumnDataSource
+ dateService: DateService
) {
this.eventManager = eventManager;
this.allDayEventRenderer = allDayEventRenderer;
this.dateService = dateService;
- this.dataSource = dataSource;
// Sync CSS variable with TypeScript constant to ensure consistency
document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`);
@@ -144,7 +140,7 @@ export class AllDayManager {
// Recalculate layout WITHOUT the removed event to compress gaps
const remainingEvents = this.currentAllDayEvents.filter(e => e.id !== eventId);
- const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentColumns);
+ const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentWeekDates);
// Re-render all-day events with compressed layout
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
@@ -399,18 +395,10 @@ export class AllDayManager {
// Store current state
this.currentAllDayEvents = events;
- this.currentColumns = dayHeaders;
+ this.currentWeekDates = dayHeaders;
- // Map IColumnBounds to IColumnInfo structure (identifier + groupId)
- const columns = dayHeaders.map(column => ({
- identifier: column.identifier,
- groupId: column.element.dataset.groupId || column.identifier,
- data: new Date(), // Not used by AllDayLayoutEngine
- events: [] // Not used by AllDayLayoutEngine
- }));
-
- // Initialize layout engine with column info including groupId
- let layoutEngine = new AllDayLayoutEngine(columns);
+ // Initialize layout engine with provided week dates
+ let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.identifier));
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
return layoutEngine.calculateLayout(events);
@@ -501,43 +489,23 @@ export class AllDayManager {
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = clone.eventId.replace('clone-', '');
- const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
-
- // Determine target date based on mode
- let targetDate: Date;
- let resourceId: string | undefined;
-
- if (this.dataSource.isResource()) {
- // Resource mode: keep event's existing date, set resourceId
- targetDate = clone.start;
- resourceId = columnIdentifier;
- } else {
- // Date mode: parse date from column identifier
- targetDate = this.dateService.parseISO(columnIdentifier);
- }
-
- console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate, resourceId });
+ const targetDate = this.dateService.parseISO(dragEndEvent.finalPosition.column.identifier);
+ console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate });
+
// Create new dates preserving time
const newStart = new Date(targetDate);
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
-
+
const newEnd = new Date(targetDate);
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
-
- // Build update payload
- const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
+
+ // Update event in repository
+ await this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd,
allDay: true
- };
-
- if (resourceId) {
- updatePayload.resourceId = resourceId;
- }
-
- // Update event in repository
- await this.eventManager.updateEvent(eventId, updatePayload);
+ });
// Remove original timed event
this.fadeOutAndRemove(dragEndEvent.originalElement);
@@ -554,7 +522,7 @@ export class AllDayManager {
};
const updatedEvents = [...this.currentAllDayEvents, newEvent];
- const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
+ const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentWeekDates);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height
@@ -569,45 +537,25 @@ export class AllDayManager {
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = clone.eventId.replace('clone-', '');
- const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
-
- // Determine target date based on mode
- let targetDate: Date;
- let resourceId: string | undefined;
-
- if (this.dataSource.isResource()) {
- // Resource mode: keep event's existing date, set resourceId
- targetDate = clone.start;
- resourceId = columnIdentifier;
- } else {
- // Date mode: parse date from column identifier
- targetDate = this.dateService.parseISO(columnIdentifier);
- }
+ const targetDate = this.dateService.parseISO(dragEndEvent.finalPosition.column.identifier);
// Calculate duration in days
const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start);
-
+
// Create new dates preserving time
const newStart = new Date(targetDate);
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
-
+
const newEnd = new Date(targetDate);
newEnd.setDate(newEnd.getDate() + durationDays);
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
-
- // Build update payload
- const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
+
+ // Update event in repository
+ await this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd,
allDay: true
- };
-
- if (resourceId) {
- updatePayload.resourceId = resourceId;
- }
-
- // Update event in repository
- await this.eventManager.updateEvent(eventId, updatePayload);
+ });
// Remove original and fade out
this.fadeOutAndRemove(dragEndEvent.originalElement);
@@ -616,7 +564,7 @@ export class AllDayManager {
const updatedEvents = this.currentAllDayEvents.map(e =>
e.id === eventId ? { ...e, start: newStart, end: newEnd } : e
);
- const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
+ const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentWeekDates);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height - this also handles overflow classes!
diff --git a/src/managers/DragDropManager.ts b/src/managers/DragDropManager.ts
index 11c6952..209de3d 100644
--- a/src/managers/DragDropManager.ts
+++ b/src/managers/DragDropManager.ts
@@ -457,20 +457,12 @@ export class DragDropManager {
if (!dropTarget)
throw "dropTarget is null";
- // Read date and resourceId directly from DOM
- const dateString = column.element.dataset.date;
- if (!dateString) {
- throw "column.element.dataset.date is not set";
- }
- const date = new Date(dateString);
- const resourceId = column.element.dataset.resourceId; // undefined in date mode
-
const dragEndPayload: IDragEndEventPayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
mousePosition,
originalSourceColumn: this.originalSourceColumn!!,
- finalPosition: { column, date, resourceId, snappedY },
+ finalPosition: { column, snappedY }, // Where drag ended
target: dropTarget
};
diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts
index 8310c59..82605c5 100644
--- a/src/managers/EventManager.ts
+++ b/src/managers/EventManager.ts
@@ -2,39 +2,38 @@ import { IEventBus, ICalendarEvent } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { Configuration } from '../configurations/CalendarConfig';
import { DateService } from '../utils/DateService';
-import { EventService } from '../storage/events/EventService';
-import { IEntityService } from '../storage/IEntityService';
+import { IEventRepository } from '../repositories/IEventRepository';
/**
* EventManager - Event lifecycle and CRUD operations
- * Delegates all data operations to EventService
- * EventService provides CRUD operations via BaseEntityService (save, delete, getAll)
+ * Delegates all data operations to IEventRepository
+ * No longer maintains in-memory cache - repository is single source of truth
*/
export class EventManager {
private dateService: DateService;
private config: Configuration;
- private eventService: EventService;
+ private repository: IEventRepository;
constructor(
private eventBus: IEventBus,
dateService: DateService,
config: Configuration,
- eventService: IEntityService
+ repository: IEventRepository
) {
this.dateService = dateService;
this.config = config;
- this.eventService = eventService as EventService;
+ this.repository = repository;
}
/**
- * Load event data from service
- * Ensures data is loaded (called during initialization)
+ * Load event data from repository
+ * No longer caches - delegates to repository
*/
public async loadData(): Promise {
try {
- // Just ensure service is ready - getAll() will return data
- await this.eventService.getAll();
+ // Just ensure repository is ready - no caching
+ await this.repository.loadEvents();
} catch (error) {
console.error('Failed to load event data:', error);
throw error;
@@ -42,19 +41,19 @@ export class EventManager {
}
/**
- * Get all events from service
+ * Get all events from repository
*/
public async getEvents(copy: boolean = false): Promise {
- const events = await this.eventService.getAll();
+ const events = await this.repository.loadEvents();
return copy ? [...events] : events;
}
/**
- * Get event by ID from service
+ * Get event by ID from repository
*/
public async getEventById(id: string): Promise {
- const event = await this.eventService.get(id);
- return event || undefined;
+ const events = await this.repository.loadEvents();
+ return events.find(event => event.id === id);
}
/**
@@ -117,7 +116,7 @@ export class EventManager {
* Get events that overlap with a given time period
*/
public async getEventsForPeriod(startDate: Date, endDate: Date): Promise {
- const events = await this.eventService.getAll();
+ const events = await this.repository.loadEvents();
// Event overlaps period if it starts before period ends AND ends after period starts
return events.filter(event => {
return event.start <= endDate && event.end >= startDate;
@@ -126,19 +125,10 @@ export class EventManager {
/**
* Create a new event and add it to the calendar
- * Generates ID and saves via EventService
+ * Delegates to repository with source='local'
*/
public async addEvent(event: Omit): Promise {
- // Generate unique ID
- const id = `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
-
- const newEvent: ICalendarEvent = {
- ...event,
- id,
- syncStatus: 'synced' // No queue yet, mark as synced
- };
-
- await this.eventService.save(newEvent);
+ const newEvent = await this.repository.createEvent(event, 'local');
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
event: newEvent
@@ -149,23 +139,11 @@ export class EventManager {
/**
* Update an existing event
- * Merges updates with existing event and saves
+ * Delegates to repository with source='local'
*/
public async updateEvent(id: string, updates: Partial): Promise {
try {
- const existingEvent = await this.eventService.get(id);
- if (!existingEvent) {
- throw new Error(`Event with ID ${id} not found`);
- }
-
- const updatedEvent: ICalendarEvent = {
- ...existingEvent,
- ...updates,
- id, // Ensure ID doesn't change
- syncStatus: 'synced' // No queue yet, mark as synced
- };
-
- await this.eventService.save(updatedEvent);
+ const updatedEvent = await this.repository.updateEvent(id, updates, 'local');
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event: updatedEvent
@@ -180,11 +158,11 @@ export class EventManager {
/**
* Delete an event
- * Calls EventService.delete()
+ * Delegates to repository with source='local'
*/
public async deleteEvent(id: string): Promise {
try {
- await this.eventService.delete(id);
+ await this.repository.deleteEvent(id, 'local');
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
eventId: id
@@ -196,4 +174,24 @@ export class EventManager {
return false;
}
}
+
+ /**
+ * Handle remote update from SignalR
+ * Delegates to repository with source='remote'
+ */
+ public async handleRemoteUpdate(event: ICalendarEvent): Promise {
+ try {
+ await this.repository.updateEvent(event.id, event, 'remote');
+
+ this.eventBus.emit(CoreEvents.REMOTE_UPDATE_RECEIVED, {
+ event
+ });
+
+ this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
+ event
+ });
+ } catch (error) {
+ console.error(`Failed to handle remote update for event ${event.id}:`, error);
+ }
+ }
}
diff --git a/src/managers/GridManager.ts b/src/managers/GridManager.ts
index ad02681..36cf352 100644
--- a/src/managers/GridManager.ts
+++ b/src/managers/GridManager.ts
@@ -1,8 +1,6 @@
/**
* GridManager - Simplified grid manager using centralized GridRenderer
* Delegates DOM rendering to GridRenderer, focuses on coordination
- *
- * Note: Events are now provided by IColumnDataSource (each column has its own events)
*/
import { eventBus } from '../core/EventBus';
@@ -12,6 +10,7 @@ import { GridRenderer } from '../renderers/GridRenderer';
import { DateService } from '../utils/DateService';
import { IColumnDataSource } from '../types/ColumnDataSource';
import { Configuration } from '../configurations/CalendarConfig';
+import { EventManager } from './EventManager';
/**
* Simplified GridManager focused on coordination, delegates rendering to GridRenderer
@@ -24,16 +23,19 @@ export class GridManager {
private dateService: DateService;
private config: Configuration;
private dataSource: IColumnDataSource;
+ private eventManager: EventManager;
constructor(
gridRenderer: GridRenderer,
dateService: DateService,
config: Configuration,
+ eventManager: EventManager,
dataSource: IColumnDataSource
) {
this.gridRenderer = gridRenderer;
this.dateService = dateService;
this.config = config;
+ this.eventManager = eventManager;
this.dataSource = dataSource;
this.init();
}
@@ -80,25 +82,28 @@ export class GridManager {
/**
* Main render method - delegates to GridRenderer
* Note: CSS variables are automatically updated by ConfigManager when config changes
- * Note: Events are included in columns from IColumnDataSource
*/
public async render(): Promise {
if (!this.container) {
return;
}
- // Get columns from datasource - single source of truth (includes events per column)
- const columns = await this.dataSource.getColumns();
+ // Get columns from datasource - single source of truth
+ const columns = this.dataSource.getColumns();
- // Set grid columns CSS variable based on actual column count
- document.documentElement.style.setProperty('--grid-columns', columns.length.toString());
+ // Extract dates for EventManager query
+ const dates = columns.map(col => col.data as Date);
+ const startDate = dates[0];
+ const endDate = dates[dates.length - 1];
+ const events = await this.eventManager.getEventsForPeriod(startDate, endDate);
- // Delegate to GridRenderer with columns (events are inside each column)
+ // Delegate to GridRenderer with columns and events
this.gridRenderer.renderGrid(
this.container,
this.currentDate,
this.currentView,
- columns
+ columns,
+ events
);
// Emit grid rendered event
diff --git a/src/managers/HeaderManager.ts b/src/managers/HeaderManager.ts
index 41b0358..e12ef4f 100644
--- a/src/managers/HeaderManager.ts
+++ b/src/managers/HeaderManager.ts
@@ -99,7 +99,7 @@ export class HeaderManager {
/**
* Update header content for navigation
*/
- private async updateHeader(currentDate: Date): Promise {
+ private updateHeader(currentDate: Date): void {
console.log('🎯 HeaderManager.updateHeader called', {
currentDate,
rendererType: this.headerRenderer.constructor.name
@@ -116,7 +116,7 @@ export class HeaderManager {
// Update DataSource with current date and get columns
this.dataSource.setCurrentDate(currentDate);
- const columns = await this.dataSource.getColumns();
+ const columns = this.dataSource.getColumns();
// Render new header content using injected renderer
const context: IHeaderRenderContext = {
diff --git a/src/managers/NavigationManager.ts b/src/managers/NavigationManager.ts
index ead6dd0..3aa8b8d 100644
--- a/src/managers/NavigationManager.ts
+++ b/src/managers/NavigationManager.ts
@@ -173,7 +173,7 @@ export class NavigationManager {
/**
* Animation transition using pre-rendered containers when available
*/
- private async animateTransition(direction: 'prev' | 'next', targetWeek: Date): Promise {
+ private animateTransition(direction: 'prev' | 'next', targetWeek: Date): void {
const container = document.querySelector('swp-calendar-container') as HTMLElement;
const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])') as HTMLElement;
@@ -194,10 +194,10 @@ export class NavigationManager {
// Update DataSource with target week and get columns
this.dataSource.setCurrentDate(targetWeek);
- const columns = await this.dataSource.getColumns();
+ const columns = this.dataSource.getColumns();
// Always create a fresh container for consistent behavior
- newGrid = this.gridRenderer.createNavigationGrid(container, columns, targetWeek);
+ newGrid = this.gridRenderer.createNavigationGrid(container, columns);
console.groupEnd();
diff --git a/src/renderers/ColumnRenderer.ts b/src/renderers/ColumnRenderer.ts
index a74c07a..638cd88 100644
--- a/src/renderers/ColumnRenderer.ts
+++ b/src/renderers/ColumnRenderer.ts
@@ -18,7 +18,6 @@ export interface IColumnRenderer {
export interface IColumnRenderContext {
columns: IColumnInfo[];
config: Configuration;
- currentDate?: Date; // Optional: Only used by ResourceColumnRenderer in resource mode
}
/**
@@ -44,7 +43,6 @@ export class DateColumnRenderer implements IColumnRenderer {
const column = document.createElement('swp-day-column');
column.dataset.columnId = columnInfo.identifier;
- column.dataset.date = this.dateService.formatISODate(date);
// Apply work hours styling
this.applyWorkHoursToColumn(column, date);
diff --git a/src/renderers/DateHeaderRenderer.ts b/src/renderers/DateHeaderRenderer.ts
index d6584fa..bc5eff8 100644
--- a/src/renderers/DateHeaderRenderer.ts
+++ b/src/renderers/DateHeaderRenderer.ts
@@ -53,7 +53,6 @@ export class DateHeaderRenderer implements IHeaderRenderer {
`;
header.dataset.columnId = columnInfo.identifier;
- header.dataset.groupId = columnInfo.groupId;
calendarHeader.appendChild(header);
});
diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts
index 853a982..045ffc6 100644
--- a/src/renderers/EventRenderer.ts
+++ b/src/renderers/EventRenderer.ts
@@ -1,7 +1,6 @@
// Event rendering strategy interface and implementations
import { ICalendarEvent } from '../types/CalendarTypes';
-import { IColumnInfo } from '../types/ColumnDataSource';
import { Configuration } from '../configurations/CalendarConfig';
import { SwpEventElement } from '../elements/SwpEventElement';
import { PositionUtils } from '../utils/PositionUtils';
@@ -13,12 +12,9 @@ import { EventLayoutCoordinator, IGridGroupLayout, IStackedEventLayout } from '.
/**
* Interface for event rendering strategies
- *
- * Note: renderEvents now receives columns with pre-filtered events,
- * not a flat array of events. Each column contains its own events.
*/
export interface IEventRenderer {
- renderEvents(columns: IColumnInfo[], container: HTMLElement): void;
+ renderEvents(events: ICalendarEvent[], container: HTMLElement): void;
clearEvents(container?: HTMLElement): void;
renderSingleColumnEvents?(column: IColumnBounds, events: ICalendarEvent[]): void;
handleDragStart?(payload: IDragStartEventPayload): void;
@@ -102,22 +98,28 @@ export class DateEventRenderer implements IEventRenderer {
/**
* Handle drag move event
- * Only updates visual position and time - date stays the same
*/
public handleDragMove(payload: IDragMoveEventPayload): void {
+
const swpEvent = payload.draggedClone as SwpEventElement;
- swpEvent.updatePosition(payload.snappedY);
+ const columnDate = this.dateService.parseISO(payload.columnBounds!!.identifier);
+ swpEvent.updatePosition(columnDate, payload.snappedY);
}
/**
* Handle column change during drag
- * Only moves the element visually - no data updates here
- * Data updates happen on drag:end in EventRenderingService
*/
public handleColumnChange(payload: IDragColumnChangeEventPayload): void {
+
const eventsLayer = payload.newColumn.element.querySelector('swp-events-layer');
if (eventsLayer && payload.draggedClone.parentElement !== eventsLayer) {
eventsLayer.appendChild(payload.draggedClone);
+
+ // Recalculate timestamps with new column date
+ const currentTop = parseFloat(payload.draggedClone.style.top) || 0;
+ const swpEvent = payload.draggedClone as SwpEventElement;
+ const columnDate = this.dateService.parseISO(payload.newColumn.identifier);
+ swpEvent.updatePosition(columnDate, currentTop);
}
}
@@ -218,36 +220,32 @@ export class DateEventRenderer implements IEventRenderer {
}
- renderEvents(columns: IColumnInfo[], container: HTMLElement): void {
- // Find column DOM elements in the container
- const columnElements = this.getColumns(container);
+ renderEvents(events: ICalendarEvent[], container: HTMLElement): void {
+ // Filter out all-day events - they should be handled by AllDayEventRenderer
+ const timedEvents = events.filter(event => !event.allDay);
- // Render events for each column using pre-filtered events from IColumnInfo
- columns.forEach((columnInfo, index) => {
- const columnElement = columnElements[index];
- if (!columnElement) return;
+ // Find columns in the specific container for regular events
+ const columns = this.getColumns(container);
- // Filter out all-day events - they should be handled by AllDayEventRenderer
- const timedEvents = columnInfo.events.filter(event => !event.allDay);
+ columns.forEach(column => {
+ const columnEvents = this.getEventsForColumn(column, timedEvents);
+ const eventsLayer = column.querySelector('swp-events-layer') as HTMLElement;
- const eventsLayer = columnElement.querySelector('swp-events-layer') as HTMLElement;
- if (eventsLayer && timedEvents.length > 0) {
- this.renderColumnEvents(timedEvents, eventsLayer);
+ if (eventsLayer) {
+ this.renderColumnEvents(columnEvents, eventsLayer);
}
});
}
/**
* Render events for a single column
- * Note: events are already filtered for this column
*/
public renderSingleColumnEvents(column: IColumnBounds, events: ICalendarEvent[]): void {
- // Filter out all-day events
- const timedEvents = events.filter(event => !event.allDay);
+ const columnEvents = this.getEventsForColumn(column.element, events);
const eventsLayer = column.element.querySelector('swp-events-layer') as HTMLElement;
- if (eventsLayer && timedEvents.length > 0) {
- this.renderColumnEvents(timedEvents, eventsLayer);
+ if (eventsLayer) {
+ this.renderColumnEvents(columnEvents, eventsLayer);
}
}
@@ -390,4 +388,24 @@ export class DateEventRenderer implements IEventRenderer {
const columns = container.querySelectorAll('swp-day-column');
return Array.from(columns) as HTMLElement[];
}
+
+ protected getEventsForColumn(column: HTMLElement, events: ICalendarEvent[]): ICalendarEvent[] {
+ const columnId = column.dataset.columnId;
+ if (!columnId) {
+ return [];
+ }
+
+ // Create start and end of day for interval overlap check
+ // In date-mode, columnId is ISO date string like "2024-11-13"
+ const columnStart = this.dateService.parseISO(`${columnId}T00:00:00`);
+ const columnEnd = this.dateService.parseISO(`${columnId}T23:59:59.999`);
+
+ const columnEvents = events.filter(event => {
+ // Interval overlap: event overlaps with column day if event.start < columnEnd AND event.end > columnStart
+ const overlaps = event.start < columnEnd && event.end > columnStart;
+ return overlaps;
+ });
+
+ return columnEvents;
+ }
}
diff --git a/src/renderers/EventRendererManager.ts b/src/renderers/EventRendererManager.ts
index 17862c0..7d2f0fd 100644
--- a/src/renderers/EventRendererManager.ts
+++ b/src/renderers/EventRendererManager.ts
@@ -1,12 +1,11 @@
-import { IEventBus } from '../types/CalendarTypes';
-import { IColumnInfo, IColumnDataSource } from '../types/ColumnDataSource';
+import { IEventBus, ICalendarEvent, IRenderContext } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { EventManager } from '../managers/EventManager';
import { IEventRenderer } from './EventRenderer';
import { SwpEventElement } from '../elements/SwpEventElement';
-import { IDragStartEventPayload, IDragMoveEventPayload, IDragEndEventPayload, IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IDragMouseEnterColumnEventPayload, IDragColumnChangeEventPayload, IResizeEndEventPayload } from '../types/EventTypes';
+import { IDragStartEventPayload, IDragMoveEventPayload, IDragEndEventPayload, IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IDragMouseEnterColumnEventPayload, IDragColumnChangeEventPayload, IHeaderReadyEventPayload, IResizeEndEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService';
-
+import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
/**
* EventRenderingService - Render events i DOM med positionering using Strategy Pattern
* Håndterer event positioning og overlap detection
@@ -15,7 +14,6 @@ export class EventRenderingService {
private eventBus: IEventBus;
private eventManager: EventManager;
private strategy: IEventRenderer;
- private dataSource: IColumnDataSource;
private dateService: DateService;
private dragMouseLeaveHeaderListener: ((event: Event) => void) | null = null;
@@ -24,18 +22,54 @@ export class EventRenderingService {
eventBus: IEventBus,
eventManager: EventManager,
strategy: IEventRenderer,
- dataSource: IColumnDataSource,
dateService: DateService
) {
this.eventBus = eventBus;
this.eventManager = eventManager;
this.strategy = strategy;
- this.dataSource = dataSource;
this.dateService = dateService;
this.setupEventListeners();
}
+ /**
+ * Render events in a specific container for a given period
+ */
+ public async renderEvents(context: IRenderContext): Promise {
+ // Clear existing events in the specific container first
+ this.strategy.clearEvents(context.container);
+
+ // Get events from EventManager for the period
+ const events = await this.eventManager.getEventsForPeriod(
+ context.startDate,
+ context.endDate
+ );
+
+ if (events.length === 0) {
+ return;
+ }
+
+ // Filter events by type - only render timed events here
+ const timedEvents = events.filter(event => !event.allDay);
+
+ console.log('🎯 EventRenderingService: Event filtering', {
+ totalEvents: events.length,
+ timedEvents: timedEvents.length,
+ allDayEvents: events.length - timedEvents.length
+ });
+
+ // Render timed events using existing strategy
+ if (timedEvents.length > 0) {
+ this.strategy.renderEvents(timedEvents, context.container);
+ }
+
+ // Emit EVENTS_RENDERED event for filtering system
+ this.eventBus.emit(CoreEvents.EVENTS_RENDERED, {
+ events: events,
+ container: context.container
+ });
+ }
+
private setupEventListeners(): void {
this.eventBus.on(CoreEvents.GRID_RENDERED, (event: Event) => {
@@ -55,7 +89,6 @@ export class EventRenderingService {
/**
* Handle GRID_RENDERED event - render events in the current grid
- * Events are now pre-filtered per column by IColumnDataSource
*/
private handleGridRendered(event: CustomEvent): void {
const { container, columns } = event.detail;
@@ -64,23 +97,17 @@ export class EventRenderingService {
return;
}
- // Render events directly from columns (pre-filtered by IColumnDataSource)
- this.renderEventsFromColumns(container, columns);
- }
+ // Extract dates from columns
+ const dates = columns.map((col: any) => col.data as Date);
- /**
- * Render events from pre-filtered columns
- * Each column already contains its events (filtered by IColumnDataSource)
- */
- private renderEventsFromColumns(container: HTMLElement, columns: IColumnInfo[]): void {
- this.strategy.clearEvents(container);
- this.strategy.renderEvents(columns, container);
+ // Calculate startDate and endDate from dates array
+ const startDate = dates[0];
+ const endDate = dates[dates.length - 1];
- // Emit EVENTS_RENDERED for filtering system
- const allEvents = columns.flatMap(col => col.events);
- this.eventBus.emit(CoreEvents.EVENTS_RENDERED, {
- events: allEvents,
- container: container
+ this.renderEvents({
+ container,
+ startDate,
+ endDate
});
}
@@ -139,42 +166,29 @@ export class EventRenderingService {
private setupDragEndListener(): void {
this.eventBus.on('drag:end', async (event: Event) => {
- const { originalElement, draggedClone, finalPosition, target } = (event as CustomEvent).detail;
+
+ const { originalElement, draggedClone, originalSourceColumn, finalPosition, target } = (event as CustomEvent).detail;
const finalColumn = finalPosition.column;
const finalY = finalPosition.snappedY;
- // Only handle day column drops
+ let element = draggedClone as SwpEventElement;
+ // Only handle day column drops for EventRenderer
if (target === 'swp-day-column' && finalColumn) {
- const element = draggedClone as SwpEventElement;
if (originalElement && draggedClone && this.strategy.handleDragEnd) {
this.strategy.handleDragEnd(originalElement, draggedClone, finalColumn, finalY);
}
- // Build update payload based on mode
- const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
+ await this.eventManager.updateEvent(element.eventId, {
start: element.start,
end: element.end,
allDay: false
- };
+ });
- if (this.dataSource.isResource()) {
- // Resource mode: update resourceId, keep existing date
- updatePayload.resourceId = finalColumn.identifier;
- } else {
- // Date mode: update date from column, keep existing time
- const newDate = this.dateService.parseISO(finalColumn.identifier);
- const startTimeMinutes = this.dateService.getMinutesSinceMidnight(element.start);
- const endTimeMinutes = this.dateService.getMinutesSinceMidnight(element.end);
- updatePayload.start = this.dateService.createDateAtTime(newDate, startTimeMinutes);
- updatePayload.end = this.dateService.createDateAtTime(newDate, endTimeMinutes);
- }
-
- await this.eventManager.updateEvent(element.eventId, updatePayload);
-
- // Trigger full refresh to re-render with updated data
- this.eventBus.emit(CoreEvents.REFRESH_REQUESTED, {});
+ // Re-render affected columns for stacking/grouping (now with updated data)
+ await this.reRenderAffectedColumns(originalSourceColumn, finalColumn);
}
+
});
}
@@ -238,14 +252,27 @@ export class EventRenderingService {
this.eventBus.on('resize:end', async (event: Event) => {
const { eventId, element } = (event as CustomEvent).detail;
+ // Update event data in EventManager with new end time from resized element
const swpEvent = element as SwpEventElement;
+ const newStart = swpEvent.start;
+ const newEnd = swpEvent.end;
+
await this.eventManager.updateEvent(eventId, {
- start: swpEvent.start,
- end: swpEvent.end
+ start: newStart,
+ end: newEnd
});
- // Trigger full refresh to re-render with updated data
- this.eventBus.emit(CoreEvents.REFRESH_REQUESTED, {});
+ console.log('📝 EventRendererManager: Updated event after resize', {
+ eventId,
+ newStart,
+ newEnd
+ });
+
+ const dateIdentifier = newStart.toISOString().split('T')[0];
+ let columnBounds = ColumnDetectionUtils.getColumnBoundsByIdentifier(dateIdentifier);
+ if (columnBounds)
+ await this.renderSingleColumn(columnBounds);
+
});
}
@@ -259,6 +286,68 @@ export class EventRenderingService {
}
+ /**
+ * Re-render affected columns after drag to recalculate stacking/grouping
+ */
+ private async reRenderAffectedColumns(originalSourceColumn: IColumnBounds | null, targetColumn: IColumnBounds | null): Promise {
+ // Re-render original source column if exists
+ if (originalSourceColumn) {
+ await this.renderSingleColumn(originalSourceColumn);
+ }
+
+ // Re-render target column if exists and different from source
+ if (targetColumn && targetColumn.identifier !== originalSourceColumn?.identifier) {
+ await this.renderSingleColumn(targetColumn);
+ }
+ }
+
+ /**
+ * Clear events in a single column's events layer
+ */
+ private clearColumnEvents(eventsLayer: HTMLElement): void {
+ const existingEvents = eventsLayer.querySelectorAll('swp-event');
+ const existingGroups = eventsLayer.querySelectorAll('swp-event-group');
+
+ existingEvents.forEach(event => event.remove());
+ existingGroups.forEach(group => group.remove());
+ }
+
+ /**
+ * Render events for a single column
+ */
+ private async renderSingleColumn(column: IColumnBounds): Promise {
+ // Get events for just this column's date
+ const dateString = column.identifier;
+ const columnStart = this.dateService.parseISO(`${dateString}T00:00:00`);
+ const columnEnd = this.dateService.parseISO(`${dateString}T23:59:59.999`);
+
+ // Get events from EventManager for this single date
+ const events = await this.eventManager.getEventsForPeriod(columnStart, columnEnd);
+
+ // Filter to timed events only
+ const timedEvents = events.filter(event => !event.allDay);
+
+ // Get events layer within this specific column
+ const eventsLayer = column.element.querySelector('swp-events-layer') as HTMLElement;
+ if (!eventsLayer) {
+ console.warn('EventRendererManager: Events layer not found in column');
+ return;
+ }
+
+ // Clear only this column's events
+ this.clearColumnEvents(eventsLayer);
+
+ // Render events for this column using strategy
+ if (this.strategy.renderSingleColumnEvents) {
+ this.strategy.renderSingleColumnEvents(column, timedEvents);
+ }
+
+ console.log('🔄 EventRendererManager: Re-rendered single column', {
+ columnDate: column.identifier,
+ eventsCount: timedEvents.length
+ });
+ }
+
private clearEvents(container?: HTMLElement): void {
this.strategy.clearEvents(container);
}
diff --git a/src/renderers/GridRenderer.ts b/src/renderers/GridRenderer.ts
index 19f267b..3929c49 100644
--- a/src/renderers/GridRenderer.ts
+++ b/src/renderers/GridRenderer.ts
@@ -1,5 +1,5 @@
import { Configuration } from '../configurations/CalendarConfig';
-import { CalendarView } from '../types/CalendarTypes';
+import { CalendarView, ICalendarEvent } from '../types/CalendarTypes';
import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
import { eventBus } from '../core/EventBus';
import { DateService } from '../utils/DateService';
@@ -105,13 +105,15 @@ export class GridRenderer {
* @param grid - Container element where grid will be rendered
* @param currentDate - Base date for the current view (e.g., any date in the week)
* @param view - Calendar view type (day/week/month)
- * @param columns - Array of columns to render (each column contains its events)
+ * @param dates - Array of dates to render as columns
+ * @param events - All events for the period
*/
public renderGrid(
grid: HTMLElement,
currentDate: Date,
view: CalendarView = 'week',
- columns: IColumnInfo[] = []
+ columns: IColumnInfo[] = [],
+ events: ICalendarEvent[] = []
): void {
if (!grid || !currentDate) {
@@ -123,10 +125,10 @@ export class GridRenderer {
// Only clear and rebuild if grid is empty (first render)
if (grid.children.length === 0) {
- this.createCompleteGridStructure(grid, currentDate, view, columns);
+ this.createCompleteGridStructure(grid, currentDate, view, columns, events);
} else {
// Optimized update - only refresh dynamic content
- this.updateGridContent(grid, currentDate, view, columns);
+ this.updateGridContent(grid, currentDate, view, columns, events);
}
}
@@ -144,13 +146,14 @@ export class GridRenderer {
* @param grid - Parent container
* @param currentDate - Current view date
* @param view - View type
- * @param columns - Array of columns to render (each column contains its events)
+ * @param dates - Array of dates to render
*/
private createCompleteGridStructure(
grid: HTMLElement,
currentDate: Date,
view: CalendarView,
- columns: IColumnInfo[]
+ columns: IColumnInfo[],
+ events: ICalendarEvent[]
): void {
// Create all elements in memory first for better performance
const fragment = document.createDocumentFragment();
@@ -165,7 +168,7 @@ export class GridRenderer {
fragment.appendChild(timeAxis);
// Create grid container with caching
- const gridContainer = this.createOptimizedGridContainer(columns, currentDate);
+ const gridContainer = this.createOptimizedGridContainer(columns, events);
this.cachedGridContainer = gridContainer;
fragment.appendChild(gridContainer);
@@ -210,13 +213,14 @@ export class GridRenderer {
* - Time grid (grid lines + day columns) - structure created here
* - Column container - created here, populated by ColumnRenderer
*
- * @param columns - Array of columns to render (each column contains its events)
* @param currentDate - Current view date
+ * @param view - View type
+ * @param dates - Array of dates to render
* @returns Complete grid container element
*/
private createOptimizedGridContainer(
columns: IColumnInfo[],
- currentDate: Date
+ events: ICalendarEvent[]
): HTMLElement {
const gridContainer = document.createElement('swp-grid-container');
@@ -234,7 +238,7 @@ export class GridRenderer {
// Create column container
const columnContainer = document.createElement('swp-day-columns');
- this.renderColumnContainer(columnContainer, columns, currentDate);
+ this.renderColumnContainer(columnContainer, columns, events);
timeGrid.appendChild(columnContainer);
scrollableContent.appendChild(timeGrid);
@@ -251,19 +255,18 @@ export class GridRenderer {
* Event rendering is handled by EventRenderingService listening to GRID_RENDERED.
*
* @param columnContainer - Empty container to populate
- * @param columns - Array of columns to render (each column contains its events)
- * @param currentDate - Current view date
+ * @param dates - Array of dates to render
+ * @param events - All events for the period (passed through, not used here)
*/
private renderColumnContainer(
columnContainer: HTMLElement,
columns: IColumnInfo[],
- currentDate: Date
+ events: ICalendarEvent[]
): void {
// Delegate to ColumnRenderer
this.columnRenderer.render(columnContainer, {
columns: columns,
- config: this.config,
- currentDate: currentDate
+ config: this.config
});
}
@@ -276,19 +279,21 @@ export class GridRenderer {
* @param grid - Existing grid element
* @param currentDate - New view date
* @param view - View type
- * @param columns - Array of columns to render (each column contains its events)
+ * @param dates - Array of dates to render
+ * @param events - All events for the period
*/
private updateGridContent(
grid: HTMLElement,
currentDate: Date,
view: CalendarView,
- columns: IColumnInfo[]
+ columns: IColumnInfo[],
+ events: ICalendarEvent[]
): void {
// Update column container if needed
const columnContainer = grid.querySelector('swp-day-columns');
if (columnContainer) {
columnContainer.innerHTML = '';
- this.renderColumnContainer(columnContainer as HTMLElement, columns, currentDate);
+ this.renderColumnContainer(columnContainer as HTMLElement, columns, events);
}
}
/**
@@ -301,13 +306,12 @@ export class GridRenderer {
* Events will be rendered by EventRenderingService when GRID_RENDERED emits.
*
* @param parentContainer - Container for the new grid
- * @param columns - Array of columns to render
- * @param currentDate - Current view date
+ * @param dates - Array of dates to render
* @returns New grid element ready for animation
*/
- public createNavigationGrid(parentContainer: HTMLElement, columns: IColumnInfo[], currentDate: Date): HTMLElement {
- // Create grid structure (events are in columns, rendered by EventRenderingService)
- const newGrid = this.createOptimizedGridContainer(columns, currentDate);
+ public createNavigationGrid(parentContainer: HTMLElement, columns: IColumnInfo[]): HTMLElement {
+ // Create grid structure without events (events rendered by EventRenderingService)
+ const newGrid = this.createOptimizedGridContainer(columns, []);
// Position new grid for animation - NO transform here, let Animation API handle it
newGrid.style.position = 'absolute';
diff --git a/src/renderers/ResourceColumnRenderer.ts b/src/renderers/ResourceColumnRenderer.ts
deleted file mode 100644
index 627546d..0000000
--- a/src/renderers/ResourceColumnRenderer.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { WorkHoursManager } from '../managers/WorkHoursManager';
-import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
-import { DateService } from '../utils/DateService';
-
-/**
- * Resource-based column renderer
- *
- * In resource mode, columns represent resources (people, rooms, etc.)
- * Work hours are hardcoded (09:00-18:00) for all columns.
- * TODO: Each resource should have its own work hours.
- */
-export class ResourceColumnRenderer implements IColumnRenderer {
- private workHoursManager: WorkHoursManager;
- private dateService: DateService;
-
- constructor(workHoursManager: WorkHoursManager, dateService: DateService) {
- this.workHoursManager = workHoursManager;
- this.dateService = dateService;
- }
-
- render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
- const { columns, currentDate } = context;
-
- if (!currentDate) {
- throw new Error('ResourceColumnRenderer requires currentDate in context');
- }
-
- // Hardcoded work hours for all resources: 09:00 - 18:00
- const workHours = { start: 9, end: 18 };
-
- columns.forEach((columnInfo) => {
- const column = document.createElement('swp-day-column');
-
- column.dataset.columnId = columnInfo.identifier;
- column.dataset.date = this.dateService.formatISODate(currentDate);
-
- // Apply hardcoded work hours to all resource columns
- this.applyWorkHoursToColumn(column, workHours);
-
- const eventsLayer = document.createElement('swp-events-layer');
- column.appendChild(eventsLayer);
-
- columnContainer.appendChild(column);
- });
- }
-
- private applyWorkHoursToColumn(column: HTMLElement, workHours: { start: number; end: number }): void {
- const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours);
- if (nonWorkStyle) {
- column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`);
- column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`);
- }
- }
-}
diff --git a/src/renderers/ResourceHeaderRenderer.ts b/src/renderers/ResourceHeaderRenderer.ts
deleted file mode 100644
index dd8bd29..0000000
--- a/src/renderers/ResourceHeaderRenderer.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { IHeaderRenderer, IHeaderRenderContext } from './DateHeaderRenderer';
-import { IResource } from '../types/ResourceTypes';
-
-/**
- * ResourceHeaderRenderer - Renders resource-based headers
- *
- * Displays resource information (avatar, name) instead of dates.
- * Used in resource mode where columns represent people/rooms/equipment.
- */
-export class ResourceHeaderRenderer implements IHeaderRenderer {
- render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void {
- const { columns } = context;
-
- // Create all-day container (same structure as date mode)
- const allDayContainer = document.createElement('swp-allday-container');
- calendarHeader.appendChild(allDayContainer);
-
- columns.forEach((columnInfo) => {
- const resource = columnInfo.data as IResource;
- const header = document.createElement('swp-day-header');
-
- // Build header content
- let avatarHtml = '';
- if (resource.avatarUrl) {
- avatarHtml = `
`;
- } else {
- // Fallback: initials
- const initials = this.getInitials(resource.displayName);
- const bgColor = resource.color || '#6366f1';
- avatarHtml = `${initials}`;
- }
-
- header.innerHTML = `
-
- `;
-
- header.dataset.columnId = columnInfo.identifier;
- header.dataset.resourceId = resource.id;
- header.dataset.groupId = columnInfo.groupId;
-
- calendarHeader.appendChild(header);
- });
- }
-
- /**
- * Get initials from display name
- */
- private getInitials(name: string): string {
- return name
- .split(' ')
- .map(part => part.charAt(0))
- .join('')
- .toUpperCase()
- .substring(0, 2);
- }
-}
diff --git a/src/repositories/IEventRepository.ts b/src/repositories/IEventRepository.ts
new file mode 100644
index 0000000..da8e131
--- /dev/null
+++ b/src/repositories/IEventRepository.ts
@@ -0,0 +1,56 @@
+import { ICalendarEvent } from '../types/CalendarTypes';
+
+/**
+ * Update source type
+ * - 'local': Changes made by the user locally (needs sync)
+ * - 'remote': Changes from API/SignalR (already synced)
+ */
+export type UpdateSource = 'local' | 'remote';
+
+/**
+ * IEventRepository - Interface for event data access
+ *
+ * Abstracts the data source for calendar events, allowing easy switching
+ * between IndexedDB, REST API, GraphQL, or other data sources.
+ *
+ * Implementations:
+ * - IndexedDBEventRepository: Local storage with offline support
+ * - MockEventRepository: (Legacy) Loads from local JSON file
+ * - ApiEventRepository: (Future) Loads from backend API
+ */
+export interface IEventRepository {
+ /**
+ * Load all calendar events from the data source
+ * @returns Promise resolving to array of ICalendarEvent objects
+ * @throws Error if loading fails
+ */
+ loadEvents(): Promise;
+
+ /**
+ * Create a new event
+ * @param event - Event to create (without ID, will be generated)
+ * @param source - Source of the update ('local' or 'remote')
+ * @returns Promise resolving to the created event with generated ID
+ * @throws Error if creation fails
+ */
+ createEvent(event: Omit, source?: UpdateSource): Promise;
+
+ /**
+ * Update an existing event
+ * @param id - ID of the event to update
+ * @param updates - Partial event data to update
+ * @param source - Source of the update ('local' or 'remote')
+ * @returns Promise resolving to the updated event
+ * @throws Error if update fails or event not found
+ */
+ updateEvent(id: string, updates: Partial, source?: UpdateSource): Promise;
+
+ /**
+ * Delete an event
+ * @param id - ID of the event to delete
+ * @param source - Source of the update ('local' or 'remote')
+ * @returns Promise resolving when deletion is complete
+ * @throws Error if deletion fails or event not found
+ */
+ deleteEvent(id: string, source?: UpdateSource): Promise;
+}
diff --git a/src/repositories/IndexedDBEventRepository.ts b/src/repositories/IndexedDBEventRepository.ts
new file mode 100644
index 0000000..12193e0
--- /dev/null
+++ b/src/repositories/IndexedDBEventRepository.ts
@@ -0,0 +1,179 @@
+import { ICalendarEvent } from '../types/CalendarTypes';
+import { IEventRepository, UpdateSource } from './IEventRepository';
+import { IndexedDBService } from '../storage/IndexedDBService';
+import { EventService } from '../storage/events/EventService';
+import { OperationQueue } from '../storage/OperationQueue';
+
+/**
+ * IndexedDBEventRepository
+ * Offline-first repository using IndexedDB as single source of truth
+ *
+ * All CRUD operations:
+ * - Save to IndexedDB immediately via EventService (always succeeds)
+ * - Add to sync queue if source is 'local'
+ * - Background SyncManager processes queue to sync with API
+ */
+export class IndexedDBEventRepository implements IEventRepository {
+ private indexedDB: IndexedDBService;
+ private eventService: EventService;
+ private queue: OperationQueue;
+
+ constructor(indexedDB: IndexedDBService, queue: OperationQueue) {
+ this.indexedDB = indexedDB;
+ this.queue = queue;
+ // EventService will be initialized after IndexedDB is ready
+ this.eventService = null as any;
+ }
+
+ /**
+ * Ensure EventService is initialized with database connection
+ */
+ private ensureEventService(): void {
+ if (!this.eventService && this.indexedDB.isInitialized()) {
+ const db = (this.indexedDB as any).db; // Access private db property
+ this.eventService = new EventService(db);
+ }
+ }
+
+ /**
+ * Load all events from IndexedDB
+ * Ensures IndexedDB is initialized on first call
+ */
+ async loadEvents(): Promise {
+ // Lazy initialization on first data load
+ if (!this.indexedDB.isInitialized()) {
+ await this.indexedDB.initialize();
+ // TODO: Seeding should be done at application level, not here
+ }
+
+ this.ensureEventService();
+ return await this.eventService.getAll();
+ }
+
+ /**
+ * Create a new event
+ * - Generates ID
+ * - Saves to IndexedDB
+ * - Adds to queue if local (needs sync)
+ */
+ async createEvent(event: Omit, source: UpdateSource = 'local'): Promise {
+ // Generate unique ID
+ const id = this.generateEventId();
+
+ // Determine sync status based on source
+ const syncStatus = source === 'local' ? 'pending' : 'synced';
+
+ // Create full event object
+ const newEvent: ICalendarEvent = {
+ ...event,
+ id,
+ syncStatus
+ } as ICalendarEvent;
+
+ // Save to IndexedDB via EventService
+ this.ensureEventService();
+ await this.eventService.save(newEvent);
+
+ // If local change, add to sync queue
+ if (source === 'local') {
+ await this.queue.enqueue({
+ type: 'create',
+ entityId: id,
+ dataEntity: {
+ typename: 'Event',
+ data: newEvent
+ },
+ timestamp: Date.now(),
+ retryCount: 0
+ });
+ }
+
+ return newEvent;
+ }
+
+ /**
+ * Update an existing event
+ * - Updates in IndexedDB
+ * - Adds to queue if local (needs sync)
+ */
+ async updateEvent(id: string, updates: Partial, source: UpdateSource = 'local'): Promise {
+ // Get existing event via EventService
+ this.ensureEventService();
+ const existingEvent = await this.eventService.get(id);
+ if (!existingEvent) {
+ throw new Error(`Event with ID ${id} not found`);
+ }
+
+ // Determine sync status based on source
+ const syncStatus = source === 'local' ? 'pending' : 'synced';
+
+ // Merge updates
+ const updatedEvent: ICalendarEvent = {
+ ...existingEvent,
+ ...updates,
+ id, // Ensure ID doesn't change
+ syncStatus
+ };
+
+ // Save to IndexedDB via EventService
+ await this.eventService.save(updatedEvent);
+
+ // If local change, add to sync queue
+ if (source === 'local') {
+ await this.queue.enqueue({
+ type: 'update',
+ entityId: id,
+ dataEntity: {
+ typename: 'Event',
+ data: updates
+ },
+ timestamp: Date.now(),
+ retryCount: 0
+ });
+ }
+
+ return updatedEvent;
+ }
+
+ /**
+ * Delete an event
+ * - Removes from IndexedDB
+ * - Adds to queue if local (needs sync)
+ */
+ async deleteEvent(id: string, source: UpdateSource = 'local'): Promise {
+ // Check if event exists via EventService
+ this.ensureEventService();
+ const existingEvent = await this.eventService.get(id);
+ if (!existingEvent) {
+ throw new Error(`Event with ID ${id} not found`);
+ }
+
+ // If local change, add to sync queue BEFORE deleting
+ // (so we can send the delete operation to API later)
+ if (source === 'local') {
+ await this.queue.enqueue({
+ type: 'delete',
+ entityId: id,
+ dataEntity: {
+ typename: 'Event',
+ data: { id } // Minimal data for delete - just ID
+ },
+ timestamp: Date.now(),
+ retryCount: 0
+ });
+ }
+
+ // Delete from IndexedDB via EventService
+ await this.eventService.delete(id);
+ }
+
+ /**
+ * Generate unique event ID
+ * Format: {timestamp}-{random}
+ */
+ private generateEventId(): string {
+ const timestamp = Date.now();
+ const random = Math.random().toString(36).substring(2, 9);
+ return `${timestamp}-${random}`;
+ }
+}
diff --git a/src/repositories/MockAuditRepository.ts b/src/repositories/MockAuditRepository.ts
deleted file mode 100644
index 753f4b4..0000000
--- a/src/repositories/MockAuditRepository.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { IApiRepository } from './IApiRepository';
-import { IAuditEntry } from '../types/AuditTypes';
-import { EntityType } from '../types/CalendarTypes';
-
-/**
- * MockAuditRepository - Mock API repository for audit entries
- *
- * In production, this would send audit entries to the backend.
- * For development/testing, it just logs the operations.
- */
-export class MockAuditRepository implements IApiRepository {
- readonly entityType: EntityType = 'Audit';
-
- async sendCreate(entity: IAuditEntry): Promise {
- // Simulate API call delay
- await new Promise(resolve => setTimeout(resolve, 100));
-
- console.log('MockAuditRepository: Audit entry synced to backend:', {
- id: entity.id,
- entityType: entity.entityType,
- entityId: entity.entityId,
- operation: entity.operation,
- timestamp: new Date(entity.timestamp).toISOString()
- });
-
- return entity;
- }
-
- async sendUpdate(_id: string, entity: IAuditEntry): Promise {
- // Audit entries are immutable - updates should not happen
- throw new Error('Audit entries cannot be updated');
- }
-
- async sendDelete(_id: string): Promise {
- // Audit entries should never be deleted
- throw new Error('Audit entries cannot be deleted');
- }
-
- async fetchAll(): Promise {
- // For now, return empty array - audit entries are local-first
- // In production, this could fetch audit history from backend
- return [];
- }
-
- async fetchById(_id: string): Promise {
- // For now, return null - audit entries are local-first
- return null;
- }
-}
diff --git a/src/repositories/MockBookingRepository.ts b/src/repositories/MockBookingRepository.ts
deleted file mode 100644
index 7637076..0000000
--- a/src/repositories/MockBookingRepository.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { IBooking, IBookingService, BookingStatus } from '../types/BookingTypes';
-import { EntityType } from '../types/CalendarTypes';
-import { IApiRepository } from './IApiRepository';
-
-interface RawBookingData {
- id: string;
- customerId: string;
- status: string;
- createdAt: string | Date;
- services: RawBookingService[];
- totalPrice?: number;
- tags?: string[];
- notes?: string;
- [key: string]: unknown;
-}
-
-interface RawBookingService {
- serviceId: string;
- serviceName: string;
- baseDuration: number;
- basePrice: number;
- customPrice?: number;
- resourceId: string;
-}
-
-/**
- * MockBookingRepository - Loads booking data from local JSON file
- *
- * This repository implementation fetches mock booking data from a static JSON file.
- * Used for development and testing instead of API calls.
- *
- * Data Source: data/mock-bookings.json
- *
- * NOTE: Create/Update/Delete operations are not supported - throws errors.
- * Only fetchAll() is implemented for loading initial mock data.
- */
-export class MockBookingRepository implements IApiRepository {
- public readonly entityType: EntityType = 'Booking';
- private readonly dataUrl = 'data/mock-bookings.json';
-
- /**
- * Fetch all bookings from mock JSON file
- */
- public async fetchAll(): Promise {
- try {
- const response = await fetch(this.dataUrl);
-
- if (!response.ok) {
- throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`);
- }
-
- const rawData: RawBookingData[] = await response.json();
-
- return this.processBookingData(rawData);
- } catch (error) {
- console.error('Failed to load booking data:', error);
- throw error;
- }
- }
-
- /**
- * NOT SUPPORTED - MockBookingRepository is read-only
- */
- public async sendCreate(booking: IBooking): Promise {
- throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
- }
-
- /**
- * NOT SUPPORTED - MockBookingRepository is read-only
- */
- public async sendUpdate(id: string, updates: Partial): Promise {
- throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
- }
-
- /**
- * NOT SUPPORTED - MockBookingRepository is read-only
- */
- public async sendDelete(id: string): Promise {
- throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
- }
-
- private processBookingData(data: RawBookingData[]): IBooking[] {
- return data.map((booking): IBooking => ({
- ...booking,
- createdAt: new Date(booking.createdAt),
- status: booking.status as BookingStatus,
- syncStatus: 'synced' as const
- }));
- }
-}
diff --git a/src/repositories/MockCustomerRepository.ts b/src/repositories/MockCustomerRepository.ts
deleted file mode 100644
index 8b5f71c..0000000
--- a/src/repositories/MockCustomerRepository.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { ICustomer } from '../types/CustomerTypes';
-import { EntityType } from '../types/CalendarTypes';
-import { IApiRepository } from './IApiRepository';
-
-interface RawCustomerData {
- id: string;
- name: string;
- phone: string;
- email?: string;
- metadata?: Record;
- [key: string]: unknown;
-}
-
-/**
- * MockCustomerRepository - Loads customer data from local JSON file
- *
- * This repository implementation fetches mock customer data from a static JSON file.
- * Used for development and testing instead of API calls.
- *
- * Data Source: data/mock-customers.json
- *
- * NOTE: Create/Update/Delete operations are not supported - throws errors.
- * Only fetchAll() is implemented for loading initial mock data.
- */
-export class MockCustomerRepository implements IApiRepository {
- public readonly entityType: EntityType = 'Customer';
- private readonly dataUrl = 'data/mock-customers.json';
-
- /**
- * Fetch all customers from mock JSON file
- */
- public async fetchAll(): Promise {
- try {
- const response = await fetch(this.dataUrl);
-
- if (!response.ok) {
- throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`);
- }
-
- const rawData: RawCustomerData[] = await response.json();
-
- return this.processCustomerData(rawData);
- } catch (error) {
- console.error('Failed to load customer data:', error);
- throw error;
- }
- }
-
- /**
- * NOT SUPPORTED - MockCustomerRepository is read-only
- */
- public async sendCreate(customer: ICustomer): Promise {
- throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
- }
-
- /**
- * NOT SUPPORTED - MockCustomerRepository is read-only
- */
- public async sendUpdate(id: string, updates: Partial): Promise {
- throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
- }
-
- /**
- * NOT SUPPORTED - MockCustomerRepository is read-only
- */
- public async sendDelete(id: string): Promise {
- throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
- }
-
- private processCustomerData(data: RawCustomerData[]): ICustomer[] {
- return data.map((customer): ICustomer => ({
- ...customer,
- syncStatus: 'synced' as const
- }));
- }
-}
diff --git a/src/repositories/MockEventRepository.ts b/src/repositories/MockEventRepository.ts
index 9740eb1..aa2c1e4 100644
--- a/src/repositories/MockEventRepository.ts
+++ b/src/repositories/MockEventRepository.ts
@@ -1,50 +1,33 @@
-import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
+import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
-import { IApiRepository } from './IApiRepository';
+import { IEventRepository, UpdateSource } from './IEventRepository';
interface RawEventData {
- // Core fields (required)
id: string;
title: string;
start: string | Date;
end: string | Date;
type: string;
+ color?: string;
allDay?: boolean;
-
- // Denormalized references (CRITICAL for booking architecture)
- bookingId?: string; // Reference to booking (customer events only)
- resourceId?: string; // Which resource owns this slot
- customerId?: string; // Customer reference (denormalized from booking)
-
- // Optional fields
- description?: string; // Detailed event notes
- recurringId?: string; // For recurring events
- metadata?: Record; // Flexible metadata
-
- // Legacy (deprecated, keep for backward compatibility)
- color?: string; // UI-specific field
[key: string]: unknown;
}
/**
- * MockEventRepository - Loads event data from local JSON file
+ * MockEventRepository - Loads event data from local JSON file (LEGACY)
*
* This repository implementation fetches mock event data from a static JSON file.
- * Used for development and testing instead of API calls.
+ * DEPRECATED: Use IndexedDBEventRepository for offline-first functionality.
*
* Data Source: data/mock-events.json
*
* NOTE: Create/Update/Delete operations are not supported - throws errors.
- * Only fetchAll() is implemented for loading initial mock data.
+ * This is intentional to encourage migration to IndexedDBEventRepository.
*/
-export class MockEventRepository implements IApiRepository {
- public readonly entityType: EntityType = 'Event';
+export class MockEventRepository implements IEventRepository {
private readonly dataUrl = 'data/mock-events.json';
- /**
- * Fetch all events from mock JSON file
- */
- public async fetchAll(): Promise {
+ public async loadEvents(): Promise {
try {
const response = await fetch(this.dataUrl);
@@ -63,60 +46,36 @@ export class MockEventRepository implements IApiRepository {
/**
* NOT SUPPORTED - MockEventRepository is read-only
+ * Use IndexedDBEventRepository instead
*/
- public async sendCreate(event: ICalendarEvent): Promise {
- throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
+ public async createEvent(event: Omit, source?: UpdateSource): Promise {
+ throw new Error('MockEventRepository does not support createEvent. Use IndexedDBEventRepository instead.');
}
/**
* NOT SUPPORTED - MockEventRepository is read-only
+ * Use IndexedDBEventRepository instead
*/
- public async sendUpdate(id: string, updates: Partial): Promise {
- throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.');
+ public async updateEvent(id: string, updates: Partial, source?: UpdateSource): Promise {
+ throw new Error('MockEventRepository does not support updateEvent. Use IndexedDBEventRepository instead.');
}
/**
* NOT SUPPORTED - MockEventRepository is read-only
+ * Use IndexedDBEventRepository instead
*/
- public async sendDelete(id: string): Promise {
- throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.');
+ public async deleteEvent(id: string, source?: UpdateSource): Promise {
+ throw new Error('MockEventRepository does not support deleteEvent. Use IndexedDBEventRepository instead.');
}
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
- return data.map((event): ICalendarEvent => {
- // Validate event type constraints
- if (event.type === 'customer') {
- if (!event.bookingId) {
- console.warn(`Customer event ${event.id} missing bookingId`);
- }
- if (!event.resourceId) {
- console.warn(`Customer event ${event.id} missing resourceId`);
- }
- if (!event.customerId) {
- console.warn(`Customer event ${event.id} missing customerId`);
- }
- }
-
- return {
- id: event.id,
- title: event.title,
- description: event.description,
- start: new Date(event.start),
- end: new Date(event.end),
- type: event.type as CalendarEventType,
- allDay: event.allDay || false,
-
- // Denormalized references (CRITICAL for booking architecture)
- bookingId: event.bookingId,
- resourceId: event.resourceId,
- customerId: event.customerId,
-
- // Optional fields
- recurringId: event.recurringId,
- metadata: event.metadata,
-
- syncStatus: 'synced' as const
- };
- });
+ return data.map((event): ICalendarEvent => ({
+ ...event,
+ start: new Date(event.start),
+ end: new Date(event.end),
+ type: event.type as CalendarEventType,
+ allDay: event.allDay || false,
+ syncStatus: 'synced' as const
+ }));
}
}
diff --git a/src/repositories/MockResourceRepository.ts b/src/repositories/MockResourceRepository.ts
deleted file mode 100644
index 28bc838..0000000
--- a/src/repositories/MockResourceRepository.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { IResource, ResourceType } from '../types/ResourceTypes';
-import { EntityType } from '../types/CalendarTypes';
-import { IApiRepository } from './IApiRepository';
-
-interface RawResourceData {
- id: string;
- name: string;
- displayName: string;
- type: string;
- avatarUrl?: string;
- color?: string;
- isActive?: boolean;
- metadata?: Record;
- [key: string]: unknown;
-}
-
-/**
- * MockResourceRepository - Loads resource data from local JSON file
- *
- * This repository implementation fetches mock resource data from a static JSON file.
- * Used for development and testing instead of API calls.
- *
- * Data Source: data/mock-resources.json
- *
- * NOTE: Create/Update/Delete operations are not supported - throws errors.
- * Only fetchAll() is implemented for loading initial mock data.
- */
-export class MockResourceRepository implements IApiRepository {
- public readonly entityType: EntityType = 'Resource';
- private readonly dataUrl = 'data/mock-resources.json';
-
- /**
- * Fetch all resources from mock JSON file
- */
- public async fetchAll(): Promise {
- try {
- const response = await fetch(this.dataUrl);
-
- if (!response.ok) {
- throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`);
- }
-
- const rawData: RawResourceData[] = await response.json();
-
- return this.processResourceData(rawData);
- } catch (error) {
- console.error('Failed to load resource data:', error);
- throw error;
- }
- }
-
- /**
- * NOT SUPPORTED - MockResourceRepository is read-only
- */
- public async sendCreate(resource: IResource): Promise {
- throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
- }
-
- /**
- * NOT SUPPORTED - MockResourceRepository is read-only
- */
- public async sendUpdate(id: string, updates: Partial): Promise {
- throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
- }
-
- /**
- * NOT SUPPORTED - MockResourceRepository is read-only
- */
- public async sendDelete(id: string): Promise {
- throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
- }
-
- private processResourceData(data: RawResourceData[]): IResource[] {
- return data.map((resource): IResource => ({
- ...resource,
- type: resource.type as ResourceType,
- syncStatus: 'synced' as const
- }));
- }
-}
diff --git a/src/storage/BaseEntityService.ts b/src/storage/BaseEntityService.ts
index c889885..f7a8b12 100644
--- a/src/storage/BaseEntityService.ts
+++ b/src/storage/BaseEntityService.ts
@@ -1,10 +1,6 @@
-import { ISync, EntityType, SyncStatus, IEventBus } from '../types/CalendarTypes';
+import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
-import { IndexedDBContext } from './IndexedDBContext';
-import { CoreEvents } from '../constants/CoreEvents';
-import { diff } from 'json-diff-ts';
-import { IEntitySavedPayload, IEntityDeletedPayload } from '../types/EventTypes';
/**
* BaseEntityService - Abstract base class for all entity services
@@ -17,7 +13,6 @@ import { IEntitySavedPayload, IEntityDeletedPayload } from '../types/EventTypes'
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
- * - Lazy database access via IndexedDBContext
*
* SUBCLASSES MUST IMPLEMENT:
* - storeName: string (IndexedDB object store name)
@@ -32,7 +27,6 @@ import { IEntitySavedPayload, IEntityDeletedPayload } from '../types/EventTypes'
* - Type safety: Generic T ensures compile-time checking
* - Pluggable: SyncPlugin can be swapped for testing/different implementations
* - Open/Closed: New entities just extend this class
- * - Lazy database access: db requested when needed, not at construction time
*/
export abstract class BaseEntityService implements IEntityService {
// Abstract properties - must be implemented by subclasses
@@ -42,30 +36,17 @@ export abstract class BaseEntityService implements IEntityServi
// Internal composition - sync functionality
private syncPlugin: SyncPlugin;
- // IndexedDB context - provides database connection
- private context: IndexedDBContext;
-
- // EventBus for emitting entity events
- protected eventBus: IEventBus;
+ // Protected database instance - accessible to subclasses
+ protected db: IDBDatabase;
/**
- * @param context - IndexedDBContext instance (injected dependency)
- * @param eventBus - EventBus for emitting entity events
+ * @param db - IDBDatabase instance (injected dependency)
*/
- constructor(context: IndexedDBContext, eventBus: IEventBus) {
- this.context = context;
- this.eventBus = eventBus;
+ constructor(db: IDBDatabase) {
+ this.db = db;
this.syncPlugin = new SyncPlugin(this);
}
- /**
- * Get IDBDatabase instance (lazy access)
- * Protected getter accessible to subclasses and methods in this class
- */
- protected get db(): IDBDatabase {
- return this.context.getDatabase();
- }
-
/**
* Serialize entity before storing in IndexedDB
* Override in subclass if entity has Date fields or needs transformation
@@ -140,28 +121,10 @@ export abstract class BaseEntityService implements IEntityServi
/**
* Save an entity (create or update)
- * Emits ENTITY_SAVED event with operation type and changes
*
* @param entity - Entity to save
*/
async save(entity: T): Promise {
- const entityId = (entity as any).id;
-
- // Check if entity exists to determine create vs update
- const existingEntity = await this.get(entityId);
- const isCreate = existingEntity === null;
-
- // Calculate changes: full entity for create, diff for update
- let changes: any;
- if (isCreate) {
- changes = entity;
- } else {
- // Calculate diff between existing and new entity
- const existingSerialized = this.serialize(existingEntity);
- const newSerialized = this.serialize(entity);
- changes = diff(existingSerialized, newSerialized);
- }
-
const serialized = this.serialize(entity);
return new Promise((resolve, reject) => {
@@ -170,27 +133,17 @@ export abstract class BaseEntityService implements IEntityServi
const request = store.put(serialized);
request.onsuccess = () => {
- // Emit ENTITY_SAVED event
- const payload: IEntitySavedPayload = {
- entityType: this.entityType,
- entityId,
- operation: isCreate ? 'create' : 'update',
- changes,
- timestamp: Date.now()
- };
- this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
resolve();
};
request.onerror = () => {
- reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
+ reject(new Error(`Failed to save ${this.entityType} ${(entity as any).id}: ${request.error}`));
};
});
}
/**
* Delete an entity
- * Emits ENTITY_DELETED event
*
* @param id - Entity ID to delete
*/
@@ -201,14 +154,6 @@ export abstract class BaseEntityService implements IEntityServi
const request = store.delete(id);
request.onsuccess = () => {
- // Emit ENTITY_DELETED event
- const payload: IEntityDeletedPayload = {
- entityType: this.entityType,
- entityId: id,
- operation: 'delete',
- timestamp: Date.now()
- };
- this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
resolve();
};
diff --git a/src/storage/IEntityService.ts b/src/storage/IEntityService.ts
index c717598..692f8c3 100644
--- a/src/storage/IEntityService.ts
+++ b/src/storage/IEntityService.ts
@@ -4,13 +4,13 @@ import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
* IEntityService - Generic interface for entity services with sync capabilities
*
* All entity services (Event, Booking, Customer, Resource) implement this interface
- * to enable polymorphic operations across different entity types.
+ * to enable polymorphic sync status management in SyncManager.
*
* ENCAPSULATION: Services encapsulate sync status manipulation.
* SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service.
*
- * POLYMORPHISM: Both SyncManager and DataSeeder work with Array>
- * and use entityType property for runtime routing, avoiding switch statements.
+ * POLYMORFI: SyncManager works with Array> and uses
+ * entityType property for runtime routing, avoiding switch statements.
*/
export interface IEntityService {
/**
@@ -19,30 +19,6 @@ export interface IEntityService {
*/
readonly entityType: EntityType;
- // ============================================================================
- // CRUD Operations (used by DataSeeder and other consumers)
- // ============================================================================
-
- /**
- * Get all entities from IndexedDB
- * Used by DataSeeder to check if store is empty before seeding
- *
- * @returns Promise - Array of all entities
- */
- getAll(): Promise;
-
- /**
- * Save an entity (create or update) to IndexedDB
- * Used by DataSeeder to persist fetched data
- *
- * @param entity - Entity to save
- */
- save(entity: T): Promise;
-
- // ============================================================================
- // SYNC Methods (used by SyncManager)
- // ============================================================================
-
/**
* Mark entity as successfully synced with backend
* Sets syncStatus = 'synced' and persists to IndexedDB
diff --git a/src/storage/IndexedDBContext.ts b/src/storage/IndexedDBContext.ts
deleted file mode 100644
index da2d6fe..0000000
--- a/src/storage/IndexedDBContext.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { IStore } from './IStore';
-
-/**
- * IndexedDBContext - Database connection manager and provider
- *
- * RESPONSIBILITY:
- * - Opens and manages IDBDatabase connection lifecycle
- * - Creates object stores via injected IStore implementations
- * - Provides shared IDBDatabase instance to all services
- *
- * SEPARATION OF CONCERNS:
- * - This class: Connection management ONLY
- * - OperationQueue: Queue and sync state operations
- * - Entity Services: CRUD operations for specific entities
- *
- * USAGE:
- * Services inject IndexedDBContext and call getDatabase() to access db.
- * This lazy access pattern ensures db is ready when requested.
- */
-export class IndexedDBContext {
- private static readonly DB_NAME = 'CalendarDB';
- private static readonly DB_VERSION = 5; // Bumped to add syncStatus index to resources
- static readonly QUEUE_STORE = 'operationQueue';
- static readonly SYNC_STATE_STORE = 'syncState';
-
- private db: IDBDatabase | null = null;
- private initialized: boolean = false;
- private stores: IStore[];
-
- /**
- * @param stores - Array of IStore implementations injected via DI
- */
- constructor(stores: IStore[]) {
- this.stores = stores;
- }
-
- /**
- * Initialize and open the database
- * Creates all entity stores, queue store, and sync state store
- */
- async initialize(): Promise {
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(IndexedDBContext.DB_NAME, IndexedDBContext.DB_VERSION);
-
- request.onerror = () => {
- reject(new Error(`Failed to open IndexedDB: ${request.error}`));
- };
-
- request.onsuccess = () => {
- this.db = request.result;
- this.initialized = true;
- resolve();
- };
-
- request.onupgradeneeded = (event) => {
- const db = (event.target as IDBOpenDBRequest).result;
-
- // Create all entity stores via injected IStore implementations
- // Open/Closed Principle: Adding new entity only requires DI registration
- this.stores.forEach(store => {
- if (!db.objectStoreNames.contains(store.storeName)) {
- store.create(db);
- }
- });
-
- // Create operation queue store (sync infrastructure)
- if (!db.objectStoreNames.contains(IndexedDBContext.QUEUE_STORE)) {
- const queueStore = db.createObjectStore(IndexedDBContext.QUEUE_STORE, { keyPath: 'id' });
- queueStore.createIndex('timestamp', 'timestamp', { unique: false });
- }
-
- // Create sync state store (sync metadata)
- if (!db.objectStoreNames.contains(IndexedDBContext.SYNC_STATE_STORE)) {
- db.createObjectStore(IndexedDBContext.SYNC_STATE_STORE, { keyPath: 'key' });
- }
- };
- });
- }
-
- /**
- * Check if database is initialized
- */
- public isInitialized(): boolean {
- return this.initialized;
- }
-
- /**
- * Get IDBDatabase instance
- * Used by services to access the database
- *
- * @throws Error if database not initialized
- * @returns IDBDatabase instance
- */
- public getDatabase(): IDBDatabase {
- if (!this.db) {
- throw new Error('IndexedDB not initialized. Call initialize() first.');
- }
- return this.db;
- }
-
- /**
- * Close database connection
- */
- close(): void {
- if (this.db) {
- this.db.close();
- this.db = null;
- this.initialized = false;
- }
- }
-
- /**
- * Delete entire database (for testing/reset)
- */
- static async deleteDatabase(): Promise {
- return new Promise((resolve, reject) => {
- const request = indexedDB.deleteDatabase(IndexedDBContext.DB_NAME);
-
- request.onsuccess = () => {
- resolve();
- };
-
- request.onerror = () => {
- reject(new Error(`Failed to delete database: ${request.error}`));
- };
- });
- }
-}
diff --git a/src/storage/IndexedDBService.ts b/src/storage/IndexedDBService.ts
new file mode 100644
index 0000000..28707d2
--- /dev/null
+++ b/src/storage/IndexedDBService.ts
@@ -0,0 +1,277 @@
+import { IDataEntity } from '../types/CalendarTypes';
+import { IStore } from './IStore';
+
+/**
+ * Operation for the sync queue
+ * Generic structure supporting all entity types (Event, Booking, Customer, Resource)
+ */
+export interface IQueueOperation {
+ id: string;
+ type: 'create' | 'update' | 'delete';
+ entityId: string;
+ dataEntity: IDataEntity;
+ timestamp: number;
+ retryCount: number;
+}
+
+/**
+ * IndexedDB Service for Calendar App
+ * Handles database connection management and core operations
+ *
+ * Entity-specific CRUD operations are handled by specialized services:
+ * - EventService for calendar events
+ * - BookingService for bookings
+ * - CustomerService for customers
+ * - ResourceService for resources
+ */
+export class IndexedDBService {
+ private static readonly DB_NAME = 'CalendarDB';
+ private static readonly DB_VERSION = 2;
+ private static readonly QUEUE_STORE = 'operationQueue';
+ private static readonly SYNC_STATE_STORE = 'syncState';
+
+ private db: IDBDatabase | null = null;
+ private initialized: boolean = false;
+ private stores: IStore[];
+
+ /**
+ * @param stores - Array of IStore implementations injected via DI
+ */
+ constructor(stores: IStore[]) {
+ this.stores = stores;
+ }
+
+ /**
+ * Initialize and open the database
+ */
+ async initialize(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(IndexedDBService.DB_NAME, IndexedDBService.DB_VERSION);
+
+ request.onerror = () => {
+ reject(new Error(`Failed to open IndexedDB: ${request.error}`));
+ };
+
+ request.onsuccess = () => {
+ this.db = request.result;
+ this.initialized = true;
+ resolve();
+ };
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+
+ // Create all entity stores via injected IStore implementations
+ // Open/Closed Principle: Adding new entity only requires DI registration
+ this.stores.forEach(store => {
+ if (!db.objectStoreNames.contains(store.storeName)) {
+ store.create(db);
+ }
+ });
+
+ // Create operation queue store (sync infrastructure)
+ if (!db.objectStoreNames.contains(IndexedDBService.QUEUE_STORE)) {
+ const queueStore = db.createObjectStore(IndexedDBService.QUEUE_STORE, { keyPath: 'id' });
+ queueStore.createIndex('timestamp', 'timestamp', { unique: false });
+ }
+
+ // Create sync state store (sync metadata)
+ if (!db.objectStoreNames.contains(IndexedDBService.SYNC_STATE_STORE)) {
+ db.createObjectStore(IndexedDBService.SYNC_STATE_STORE, { keyPath: 'key' });
+ }
+ };
+ });
+ }
+
+ /**
+ * Check if database is initialized
+ */
+ public isInitialized(): boolean {
+ return this.initialized;
+ }
+
+ /**
+ * Ensure database is initialized
+ */
+ private ensureDB(): IDBDatabase {
+ if (!this.db) {
+ throw new Error('IndexedDB not initialized. Call initialize() first.');
+ }
+ return this.db;
+ }
+
+ // ========================================
+ // Event CRUD Operations - MOVED TO EventService
+ // ========================================
+ // Event operations have been moved to storage/events/EventService.ts
+ // for better modularity and separation of concerns.
+
+ // ========================================
+ // Queue Operations
+ // ========================================
+
+ /**
+ * Add operation to queue
+ */
+ async addToQueue(operation: Omit): Promise {
+ const db = this.ensureDB();
+ const queueItem: IQueueOperation = {
+ ...operation,
+ id: `${operation.type}-${operation.entityId}-${Date.now()}`
+ };
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
+ const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
+ const request = store.put(queueItem);
+
+ request.onsuccess = () => {
+ resolve();
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to add to queue: ${request.error}`));
+ };
+ });
+ }
+
+ /**
+ * Get all queue operations (sorted by timestamp)
+ */
+ async getQueue(): Promise {
+ const db = this.ensureDB();
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readonly');
+ const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
+ const index = store.index('timestamp');
+ const request = index.getAll();
+
+ request.onsuccess = () => {
+ resolve(request.result as IQueueOperation[]);
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to get queue: ${request.error}`));
+ };
+ });
+ }
+
+ /**
+ * Remove operation from queue
+ */
+ async removeFromQueue(id: string): Promise {
+ const db = this.ensureDB();
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
+ const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
+ const request = store.delete(id);
+
+ request.onsuccess = () => {
+ resolve();
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to remove from queue: ${request.error}`));
+ };
+ });
+ }
+
+ /**
+ * Clear entire queue
+ */
+ async clearQueue(): Promise {
+ const db = this.ensureDB();
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([IndexedDBService.QUEUE_STORE], 'readwrite');
+ const store = transaction.objectStore(IndexedDBService.QUEUE_STORE);
+ const request = store.clear();
+
+ request.onsuccess = () => {
+ resolve();
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to clear queue: ${request.error}`));
+ };
+ });
+ }
+
+ // ========================================
+ // Sync State Operations
+ // ========================================
+
+ /**
+ * Save sync state value
+ */
+ async setSyncState(key: string, value: any): Promise {
+ const db = this.ensureDB();
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([IndexedDBService.SYNC_STATE_STORE], 'readwrite');
+ const store = transaction.objectStore(IndexedDBService.SYNC_STATE_STORE);
+ const request = store.put({ key, value });
+
+ request.onsuccess = () => {
+ resolve();
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to set sync state ${key}: ${request.error}`));
+ };
+ });
+ }
+
+ /**
+ * Get sync state value
+ */
+ async getSyncState(key: string): Promise {
+ const db = this.ensureDB();
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([IndexedDBService.SYNC_STATE_STORE], 'readonly');
+ const store = transaction.objectStore(IndexedDBService.SYNC_STATE_STORE);
+ const request = store.get(key);
+
+ request.onsuccess = () => {
+ const result = request.result;
+ resolve(result ? result.value : null);
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to get sync state ${key}: ${request.error}`));
+ };
+ });
+ }
+
+ /**
+ * Close database connection
+ */
+ close(): void {
+ if (this.db) {
+ this.db.close();
+ this.db = null;
+ }
+ }
+
+ /**
+ * Delete entire database (for testing/reset)
+ */
+ static async deleteDatabase(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(IndexedDBService.DB_NAME);
+
+ request.onsuccess = () => {
+ resolve();
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to delete database: ${request.error}`));
+ };
+ });
+ }
+
+ // ========================================
+ // Seeding - REMOVED
+ // ========================================
+ // seedIfEmpty() has been removed.
+ // Seeding should be implemented at application level using EventService,
+ // BookingService, CustomerService, and ResourceService directly.
+}
diff --git a/src/storage/OperationQueue.ts b/src/storage/OperationQueue.ts
new file mode 100644
index 0000000..7a822cf
--- /dev/null
+++ b/src/storage/OperationQueue.ts
@@ -0,0 +1,125 @@
+import { IndexedDBService, IQueueOperation } from './IndexedDBService';
+
+/**
+ * Operation Queue Manager
+ * Handles FIFO queue of pending sync operations
+ */
+export class OperationQueue {
+ private indexedDB: IndexedDBService;
+
+ constructor(indexedDB: IndexedDBService) {
+ this.indexedDB = indexedDB;
+ }
+
+ /**
+ * Add operation to the end of the queue
+ */
+ async enqueue(operation: Omit): Promise {
+ await this.indexedDB.addToQueue(operation);
+ }
+
+ /**
+ * Get the first operation from the queue (without removing it)
+ * Returns null if queue is empty
+ */
+ async peek(): Promise {
+ const queue = await this.indexedDB.getQueue();
+ return queue.length > 0 ? queue[0] : null;
+ }
+
+ /**
+ * Get all operations in the queue (sorted by timestamp FIFO)
+ */
+ async getAll(): Promise {
+ return await this.indexedDB.getQueue();
+ }
+
+ /**
+ * Remove a specific operation from the queue
+ */
+ async remove(operationId: string): Promise {
+ await this.indexedDB.removeFromQueue(operationId);
+ }
+
+ /**
+ * Remove the first operation from the queue and return it
+ * Returns null if queue is empty
+ */
+ async dequeue(): Promise {
+ const operation = await this.peek();
+ if (operation) {
+ await this.remove(operation.id);
+ }
+ return operation;
+ }
+
+ /**
+ * Clear all operations from the queue
+ */
+ async clear(): Promise {
+ await this.indexedDB.clearQueue();
+ }
+
+ /**
+ * Get the number of operations in the queue
+ */
+ async size(): Promise {
+ const queue = await this.getAll();
+ return queue.length;
+ }
+
+ /**
+ * Check if queue is empty
+ */
+ async isEmpty(): Promise {
+ const size = await this.size();
+ return size === 0;
+ }
+
+ /**
+ * Get operations for a specific entity ID
+ */
+ async getOperationsForEntity(entityId: string): Promise {
+ const queue = await this.getAll();
+ return queue.filter(op => op.entityId === entityId);
+ }
+
+ /**
+ * Remove all operations for a specific entity ID
+ */
+ async removeOperationsForEntity(entityId: string): Promise