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.

- -
- -
-
-
Blå event
-
.is-blue
-
-
- -
-
-
Rød event
-
.is-red
-
-
- -
-
-
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 = `${resource.displayName}`; - } else { - // Fallback: initials - const initials = this.getInitials(resource.displayName); - const bgColor = resource.color || '#6366f1'; - avatarHtml = `${initials}`; - } - - header.innerHTML = ` -
- ${avatarHtml} - ${resource.displayName} -
- `; - - 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 { + const operations = await this.getOperationsForEntity(entityId); + for (const op of operations) { + await this.remove(op.id); + } + } + + /** + * @deprecated Use getOperationsForEntity instead + */ + async getOperationsForEvent(eventId: string): Promise { + return this.getOperationsForEntity(eventId); + } + + /** + * @deprecated Use removeOperationsForEntity instead + */ + async removeOperationsForEvent(eventId: string): Promise { + return this.removeOperationsForEntity(eventId); + } + + /** + * Update retry count for an operation + */ + async incrementRetryCount(operationId: string): Promise { + const queue = await this.getAll(); + const operation = queue.find(op => op.id === operationId); + + if (operation) { + operation.retryCount++; + // Re-add to queue with updated retry count + await this.remove(operationId); + await this.enqueue(operation); + } + } +} diff --git a/src/storage/audit/AuditService.ts b/src/storage/audit/AuditService.ts deleted file mode 100644 index 238ed87..0000000 --- a/src/storage/audit/AuditService.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; -import { IAuditEntry } from '../../types/AuditTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; -import { CoreEvents } from '../../constants/CoreEvents'; -import { IEntitySavedPayload, IEntityDeletedPayload, IAuditLoggedPayload } from '../../types/EventTypes'; - -/** - * AuditService - Entity service for audit entries - * - * RESPONSIBILITIES: - * - Store audit entries in IndexedDB - * - Listen for ENTITY_SAVED/ENTITY_DELETED events - * - Create audit entries for all entity changes - * - Emit AUDIT_LOGGED after saving (for SyncManager to listen) - * - * OVERRIDE PATTERN: - * - Overrides save() to NOT emit events (prevents infinite loops) - * - AuditService saves audit entries without triggering more audits - * - * EVENT CHAIN: - * Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager - */ -export class AuditService extends BaseEntityService { - readonly storeName = 'audit'; - readonly entityType: EntityType = 'Audit'; - - // Hardcoded userId for now - will come from session later - private static readonly DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001'; - - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - this.setupEventListeners(); - } - - /** - * Setup listeners for ENTITY_SAVED and ENTITY_DELETED events - */ - private setupEventListeners(): void { - // Listen for entity saves (create/update) - this.eventBus.on(CoreEvents.ENTITY_SAVED, (event: Event) => { - const detail = (event as CustomEvent).detail; - this.handleEntitySaved(detail); - }); - - // Listen for entity deletes - this.eventBus.on(CoreEvents.ENTITY_DELETED, (event: Event) => { - const detail = (event as CustomEvent).detail; - this.handleEntityDeleted(detail); - }); - } - - /** - * Handle ENTITY_SAVED event - create audit entry - */ - private async handleEntitySaved(payload: IEntitySavedPayload): Promise { - // Don't audit audit entries (prevent infinite loops) - if (payload.entityType === 'Audit') return; - - const auditEntry: IAuditEntry = { - id: crypto.randomUUID(), - entityType: payload.entityType, - entityId: payload.entityId, - operation: payload.operation, - userId: AuditService.DEFAULT_USER_ID, - timestamp: payload.timestamp, - changes: payload.changes, - synced: false, - syncStatus: 'pending' - }; - - await this.save(auditEntry); - } - - /** - * Handle ENTITY_DELETED event - create audit entry - */ - private async handleEntityDeleted(payload: IEntityDeletedPayload): Promise { - // Don't audit audit entries (prevent infinite loops) - if (payload.entityType === 'Audit') return; - - const auditEntry: IAuditEntry = { - id: crypto.randomUUID(), - entityType: payload.entityType, - entityId: payload.entityId, - operation: 'delete', - userId: AuditService.DEFAULT_USER_ID, - timestamp: payload.timestamp, - changes: { id: payload.entityId }, // For delete, just store the ID - synced: false, - syncStatus: 'pending' - }; - - await this.save(auditEntry); - } - - /** - * Override save to NOT trigger ENTITY_SAVED event - * Instead, emits AUDIT_LOGGED for SyncManager to listen - * - * This prevents infinite loops: - * - BaseEntityService.save() emits ENTITY_SAVED - * - AuditService listens to ENTITY_SAVED and creates audit - * - If AuditService.save() also emitted ENTITY_SAVED, it would loop - */ - async save(entity: IAuditEntry): Promise { - const serialized = this.serialize(entity); - - return new Promise((resolve, reject) => { - const transaction = this.db.transaction([this.storeName], 'readwrite'); - const store = transaction.objectStore(this.storeName); - const request = store.put(serialized); - - request.onsuccess = () => { - // Emit AUDIT_LOGGED instead of 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); - resolve(); - }; - - request.onerror = () => { - reject(new Error(`Failed to save audit entry ${entity.id}: ${request.error}`)); - }; - }); - } - - /** - * Override delete to NOT trigger ENTITY_DELETED event - * Audit entries should never be deleted (compliance requirement) - */ - async delete(_id: string): Promise { - throw new Error('Audit entries cannot be deleted (compliance requirement)'); - } - - /** - * Get pending audit entries (for sync) - */ - async getPendingAudits(): Promise { - return this.getBySyncStatus('pending'); - } - - /** - * Get audit entries for a specific entity - */ - async getByEntityId(entityId: string): Promise { - return new Promise((resolve, reject) => { - const transaction = this.db.transaction([this.storeName], 'readonly'); - const store = transaction.objectStore(this.storeName); - const index = store.index('entityId'); - const request = index.getAll(entityId); - - request.onsuccess = () => { - const entries = request.result as IAuditEntry[]; - resolve(entries); - }; - - request.onerror = () => { - reject(new Error(`Failed to get audit entries for entity ${entityId}: ${request.error}`)); - }; - }); - } -} diff --git a/src/storage/audit/AuditStore.ts b/src/storage/audit/AuditStore.ts deleted file mode 100644 index bdef64e..0000000 --- a/src/storage/audit/AuditStore.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IStore } from '../IStore'; - -/** - * AuditStore - IndexedDB store configuration for audit entries - * - * Stores all entity changes for: - * - Compliance and audit trail - * - Sync tracking with backend - * - Change history - * - * Indexes: - * - syncStatus: For finding pending entries to sync - * - synced: Boolean flag for quick sync queries - */ -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 }); - } -} diff --git a/src/storage/bookings/BookingService.ts b/src/storage/bookings/BookingService.ts index 3550627..3719666 100644 --- a/src/storage/bookings/BookingService.ts +++ b/src/storage/bookings/BookingService.ts @@ -1,9 +1,8 @@ import { IBooking } from '../../types/BookingTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; +import { EntityType } from '../../types/CalendarTypes'; import { BookingStore } from './BookingStore'; import { BookingSerialization } from './BookingSerialization'; import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; /** * BookingService - CRUD operations for bookings in IndexedDB @@ -25,10 +24,6 @@ export class BookingService extends BaseEntityService { readonly storeName = BookingStore.STORE_NAME; readonly entityType: EntityType = 'Booking'; - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } - /** * Serialize booking for IndexedDB storage * Converts Date objects to ISO strings diff --git a/src/storage/customers/CustomerService.ts b/src/storage/customers/CustomerService.ts index 8b076f0..8de8f90 100644 --- a/src/storage/customers/CustomerService.ts +++ b/src/storage/customers/CustomerService.ts @@ -1,8 +1,7 @@ import { ICustomer } from '../../types/CustomerTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; +import { EntityType } from '../../types/CalendarTypes'; import { CustomerStore } from './CustomerStore'; import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; /** * CustomerService - CRUD operations for customers in IndexedDB @@ -24,9 +23,7 @@ export class CustomerService extends BaseEntityService { readonly storeName = CustomerStore.STORE_NAME; readonly entityType: EntityType = 'Customer'; - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } + // No serialization override needed - ICustomer has no Date fields /** * Get customers by phone number diff --git a/src/storage/events/EventService.ts b/src/storage/events/EventService.ts index 7207898..ad1c847 100644 --- a/src/storage/events/EventService.ts +++ b/src/storage/events/EventService.ts @@ -1,8 +1,7 @@ -import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes'; +import { ICalendarEvent, EntityType } from '../../types/CalendarTypes'; import { EventStore } from './EventStore'; import { EventSerialization } from './EventSerialization'; import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; /** * EventService - CRUD operations for calendar events in IndexedDB @@ -27,10 +26,6 @@ export class EventService extends BaseEntityService { readonly storeName = EventStore.STORE_NAME; readonly entityType: EntityType = 'Event'; - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } - /** * Serialize event for IndexedDB storage * Converts Date objects to ISO strings diff --git a/src/storage/resources/ResourceService.ts b/src/storage/resources/ResourceService.ts index 8fd868e..45b9bbe 100644 --- a/src/storage/resources/ResourceService.ts +++ b/src/storage/resources/ResourceService.ts @@ -1,8 +1,7 @@ import { IResource } from '../../types/ResourceTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; +import { EntityType } from '../../types/CalendarTypes'; import { ResourceStore } from './ResourceStore'; import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; /** * ResourceService - CRUD operations for resources in IndexedDB @@ -25,31 +24,72 @@ export class ResourceService extends BaseEntityService { readonly storeName = ResourceStore.STORE_NAME; readonly entityType: EntityType = 'Resource'; - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } + // No serialization override needed - IResource has no Date fields /** * Get resources by type + * + * @param type - Resource type (person, room, equipment, etc.) + * @returns Array of resources of this type */ async getByType(type: string): Promise { - const all = await this.getAll(); - return all.filter(r => r.type === type); + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const index = store.index('type'); + const request = index.getAll(type); + + request.onsuccess = () => { + resolve(request.result as IResource[]); + }; + + request.onerror = () => { + reject(new Error(`Failed to get resources by type ${type}: ${request.error}`)); + }; + }); } /** * Get active resources only + * + * @returns Array of active resources (isActive = true) */ async getActive(): Promise { - const all = await this.getAll(); - return all.filter(r => r.isActive === true); + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const index = store.index('isActive'); + const request = index.getAll(IDBKeyRange.only(true)); + + request.onsuccess = () => { + resolve(request.result as IResource[]); + }; + + request.onerror = () => { + reject(new Error(`Failed to get active resources: ${request.error}`)); + }; + }); } /** * Get inactive resources + * + * @returns Array of inactive resources (isActive = false) */ async getInactive(): Promise { - const all = await this.getAll(); - return all.filter(r => r.isActive === false); + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const index = store.index('isActive'); + const request = index.getAll(IDBKeyRange.only(false)); + + request.onsuccess = () => { + resolve(request.result as IResource[]); + }; + + request.onerror = () => { + reject(new Error(`Failed to get inactive resources: ${request.error}`)); + }; + }); } } diff --git a/src/storage/resources/ResourceStore.ts b/src/storage/resources/ResourceStore.ts index 05ed171..1725777 100644 --- a/src/storage/resources/ResourceStore.ts +++ b/src/storage/resources/ResourceStore.ts @@ -20,7 +20,16 @@ export class ResourceStore implements IStore { * @param db - IDBDatabase instance */ create(db: IDBDatabase): void { + // Create ObjectStore with 'id' as keyPath const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' }); + + // Index: type (for filtering by resource category) + store.createIndex('type', 'type', { unique: false }); + + // Index: isActive (for showing/hiding inactive resources) + store.createIndex('isActive', 'isActive', { unique: false }); + + // Index: syncStatus (for querying by sync status - used by SyncPlugin) store.createIndex('syncStatus', 'syncStatus', { unique: false }); } } diff --git a/src/types/AuditTypes.ts b/src/types/AuditTypes.ts deleted file mode 100644 index 9710bb1..0000000 --- a/src/types/AuditTypes.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ISync, EntityType } from './CalendarTypes'; - -/** - * IAuditEntry - Audit log entry for tracking all entity changes - * - * Used for: - * - Compliance and audit trail - * - Sync tracking with backend - * - Change history - */ -export interface IAuditEntry extends ISync { - /** Unique audit entry ID */ - id: string; - - /** Type of entity that was changed */ - entityType: EntityType; - - /** ID of the entity that was changed */ - entityId: string; - - /** Type of operation performed */ - operation: 'create' | 'update' | 'delete'; - - /** User who made the change */ - userId: string; - - /** Timestamp when change was made */ - timestamp: number; - - /** Changes made (full entity for create, diff for update, { id } for delete) */ - changes: any; - - /** Whether this audit entry has been synced to backend */ - synced: boolean; - - /** Sync status inherited from ISync */ - syncStatus: 'synced' | 'pending' | 'error'; -} diff --git a/src/types/CalendarTypes.ts b/src/types/CalendarTypes.ts index 0b8a785..734a61d 100644 --- a/src/types/CalendarTypes.ts +++ b/src/types/CalendarTypes.ts @@ -12,7 +12,7 @@ export type SyncStatus = 'synced' | 'pending' | 'error'; /** * EntityType - Discriminator for all syncable entities */ -export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit'; +export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource'; /** * ISync - Interface composition for sync status tracking diff --git a/src/types/ColumnDataSource.ts b/src/types/ColumnDataSource.ts index 6df14ea..f933574 100644 --- a/src/types/ColumnDataSource.ts +++ b/src/types/ColumnDataSource.ts @@ -1,15 +1,13 @@ import { IResource } from './ResourceTypes'; -import { CalendarView, ICalendarEvent } from './CalendarTypes'; +import { CalendarView } from './CalendarTypes'; /** * Column information container * Contains both identifier and actual data for a column */ export interface IColumnInfo { - identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode) - data: Date | IResource; // Date for date-mode, IResource for resource-mode - events: ICalendarEvent[]; // Events for this column (pre-filtered by datasource) - groupId: string; // Group ID for spanning logic - events can only span columns with same groupId + identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode) + data: Date | IResource; // Date for date-mode, IResource for resource-mode } /** @@ -23,29 +21,19 @@ export interface IColumnDataSource { * Get the list of columns to render * @returns Array of column information */ - getColumns(): Promise; + getColumns(): IColumnInfo[]; /** * Get the type of columns this datasource provides */ getType(): 'date' | 'resource'; - /** - * Check if this datasource is in resource mode - */ - isResource(): boolean; - /** * Update the current date for column calculations * @param date - The new current date */ setCurrentDate(date: Date): void; - /** - * Get the current date - */ - getCurrentDate(): Date; - /** * Update the current view (day/week/month) * @param view - The new calendar view diff --git a/src/types/EventTypes.ts b/src/types/EventTypes.ts index db5468e..919c1b6 100644 --- a/src/types/EventTypes.ts +++ b/src/types/EventTypes.ts @@ -3,7 +3,7 @@ */ import { IColumnBounds } from "../utils/ColumnDetectionUtils"; -import { ICalendarEvent, EntityType } from "./CalendarTypes"; +import { ICalendarEvent } from "./CalendarTypes"; /** * Drag Event Payload Interfaces @@ -43,8 +43,6 @@ export interface IDragEndEventPayload { originalSourceColumn: IColumnBounds; // Original column where drag started finalPosition: { column: IColumnBounds | null; // Where drag ended - date: Date; // Always present - the date for this position - resourceId?: string; // Only in resource mode snappedY: number; }; target: 'swp-day-column' | 'swp-day-header' | null; @@ -105,30 +103,4 @@ export interface IResizeEndEventPayload { export interface INavButtonClickedEventPayload { direction: 'next' | 'previous' | 'today'; newDate: Date; -} - -// Entity saved event payload -export interface IEntitySavedPayload { - entityType: EntityType; - entityId: string; - operation: 'create' | 'update'; - changes: any; - timestamp: number; -} - -// Entity deleted event payload -export interface IEntityDeletedPayload { - entityType: EntityType; - entityId: string; - operation: 'delete'; - timestamp: number; -} - -// Audit logged event payload -export interface IAuditLoggedPayload { - auditId: string; - entityType: EntityType; - entityId: string; - operation: 'create' | 'update' | 'delete'; - timestamp: number; } \ No newline at end of file diff --git a/src/utils/AllDayLayoutEngine.ts b/src/utils/AllDayLayoutEngine.ts index 1afe7b9..a43f8e3 100644 --- a/src/utils/AllDayLayoutEngine.ts +++ b/src/utils/AllDayLayoutEngine.ts @@ -1,5 +1,4 @@ import { ICalendarEvent } from '../types/CalendarTypes'; -import { IColumnInfo } from '../types/ColumnDataSource'; export interface IEventLayout { calenderEvent: ICalendarEvent; @@ -11,13 +10,11 @@ export interface IEventLayout { } export class AllDayLayoutEngine { - private columnIdentifiers: string[]; // Column identifiers (date or resource ID) - private columnGroups: string[]; // Group ID for each column (same index as columnIdentifiers) + private weekDates: string[]; private tracks: boolean[][]; - constructor(columns: IColumnInfo[]) { - this.columnIdentifiers = columns.map(col => col.identifier); - this.columnGroups = columns.map(col => col.groupId); + constructor(weekDates: string[]) { + this.weekDates = weekDates; this.tracks = []; } @@ -28,11 +25,13 @@ export class AllDayLayoutEngine { let layouts: IEventLayout[] = []; // Reset tracks for new calculation - this.tracks = [new Array(this.columnIdentifiers.length).fill(false)]; + this.tracks = [new Array(this.weekDates.length).fill(false)]; + + // Filter to only visible events + const visibleEvents = events.filter(event => this.isEventVisible(event)); // Process events in input order (no sorting) - // Events are already filtered by DataSource before reaching this engine - for (const event of events) { + for (const event of visibleEvents) { const startDay = this.getEventStartDay(event); const endDay = this.getEventEndDay(event); @@ -71,7 +70,7 @@ export class AllDayLayoutEngine { } // Create new track if none available - this.tracks.push(new Array(this.columnIdentifiers.length).fill(false)); + this.tracks.push(new Array(this.weekDates.length).fill(false)); return this.tracks.length - 1; } @@ -89,70 +88,46 @@ export class AllDayLayoutEngine { /** * Get start day index for event (1-based, 0 if not visible) - * Clips to group boundaries - events can only span columns with same groupId */ private getEventStartDay(event: ICalendarEvent): number { const eventStartDate = this.formatDate(event.start); - const firstVisibleDate = this.columnIdentifiers[0]; + const firstVisibleDate = this.weekDates[0]; // If event starts before visible range, clip to first visible day const clippedStartDate = eventStartDate < firstVisibleDate ? firstVisibleDate : eventStartDate; - const dayIndex = this.columnIdentifiers.indexOf(clippedStartDate); - if (dayIndex < 0) return 0; - - // Find group start boundary for this column - const groupId = this.columnGroups[dayIndex]; - const groupStart = this.getGroupStartIndex(dayIndex, groupId); - - // Return the later of event start and group start (1-based) - return Math.max(groupStart, dayIndex) + 1; + const dayIndex = this.weekDates.indexOf(clippedStartDate); + return dayIndex >= 0 ? dayIndex + 1 : 0; } /** * Get end day index for event (1-based, 0 if not visible) - * Clips to group boundaries - events can only span columns with same groupId */ private getEventEndDay(event: ICalendarEvent): number { const eventEndDate = this.formatDate(event.end); - const lastVisibleDate = this.columnIdentifiers[this.columnIdentifiers.length - 1]; + const lastVisibleDate = this.weekDates[this.weekDates.length - 1]; // If event ends after visible range, clip to last visible day const clippedEndDate = eventEndDate > lastVisibleDate ? lastVisibleDate : eventEndDate; - const dayIndex = this.columnIdentifiers.indexOf(clippedEndDate); - if (dayIndex < 0) return 0; - - // Find group end boundary for this column - const groupId = this.columnGroups[dayIndex]; - const groupEnd = this.getGroupEndIndex(dayIndex, groupId); - - // Return the earlier of event end and group end (1-based) - return Math.min(groupEnd, dayIndex) + 1; + const dayIndex = this.weekDates.indexOf(clippedEndDate); + return dayIndex >= 0 ? dayIndex + 1 : 0; } /** - * Find the start index of a group (0-based) - * Scans backwards from columnIndex to find where this group starts + * Check if event is visible in the current date range */ - private getGroupStartIndex(columnIndex: number, groupId: string): number { - let startIndex = columnIndex; - while (startIndex > 0 && this.columnGroups[startIndex - 1] === groupId) { - startIndex--; - } - return startIndex; - } + private isEventVisible(event: ICalendarEvent): boolean { + if (this.weekDates.length === 0) return false; - /** - * Find the end index of a group (0-based) - * Scans forwards from columnIndex to find where this group ends - */ - private getGroupEndIndex(columnIndex: number, groupId: string): number { - let endIndex = columnIndex; - while (endIndex < this.columnGroups.length - 1 && this.columnGroups[endIndex + 1] === groupId) { - endIndex++; - } - return endIndex; + const eventStartDate = this.formatDate(event.start); + const eventEndDate = this.formatDate(event.end); + const firstVisibleDate = this.weekDates[0]; + const lastVisibleDate = this.weekDates[this.weekDates.length - 1]; + + // Event overlaps if it doesn't end before visible range starts + // AND doesn't start after visible range ends + return !(eventEndDate < firstVisibleDate || eventStartDate > lastVisibleDate); } /** diff --git a/src/workers/DataSeeder.ts b/src/workers/DataSeeder.ts deleted file mode 100644 index 01795cc..0000000 --- a/src/workers/DataSeeder.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { IApiRepository } from '../repositories/IApiRepository'; -import { IEntityService } from '../storage/IEntityService'; - -/** - * DataSeeder - Orchestrates initial data loading from repositories into IndexedDB - * - * ARCHITECTURE: - * - Repository (Mock/Api): Fetches data from source (JSON file or backend API) - * - DataSeeder (this class): Orchestrates fetch + save operations - * - Service (EventService, etc.): Saves data to IndexedDB - * - * SEPARATION OF CONCERNS: - * - Repository does NOT know about IndexedDB or storage - * - Service does NOT know about where data comes from - * - DataSeeder connects them together - * - * POLYMORPHIC DESIGN: - * - Uses arrays of IEntityService[] and IApiRepository[] - * - Matches services with repositories using entityType property - * - Open/Closed Principle: Adding new entity requires no code changes here - * - * USAGE: - * Called once during app initialization in index.ts: - * 1. IndexedDBService.initialize() - open database - * 2. dataSeeder.seedIfEmpty() - load initial data if needed - * 3. CalendarManager.initialize() - start calendar - * - * NOTE: This is for INITIAL SEEDING only. Ongoing sync is handled by SyncManager. - */ -export class DataSeeder { - constructor( - // Arrays injected via DI - automatically includes all registered services/repositories - private services: IEntityService[], - private repositories: IApiRepository[] - ) {} - - /** - * Seed all entity stores if they are empty - * Runs on app initialization to load initial data from repositories - * - * Uses polymorphism: loops through all services and matches with repositories by entityType - */ - async seedIfEmpty(): Promise { - console.log('[DataSeeder] Checking if database needs seeding...'); - - try { - // Loop through all entity services (Event, Booking, Customer, Resource, etc.) - for (const service of this.services) { - // Find matching repository for this service based on entityType - const repository = this.repositories.find(repo => repo.entityType === service.entityType); - - if (!repository) { - console.warn(`[DataSeeder] No repository found for entity type: ${service.entityType}, skipping`); - continue; - } - - // Seed this entity type - await this.seedEntity(service.entityType, service, repository); - } - - console.log('[DataSeeder] Seeding complete'); - } catch (error) { - console.error('[DataSeeder] Seeding failed:', error); - throw error; - } - } - - /** - * Generic method to seed a single entity type - * - * @param entityType - Entity type ('Event', 'Booking', 'Customer', 'Resource') - * @param service - Entity service for IndexedDB operations - * @param repository - Repository for fetching data - */ - private async seedEntity( - entityType: string, - service: IEntityService, - repository: IApiRepository - ): Promise { - // Check if store is empty - const existing = await service.getAll(); - - if (existing.length > 0) { - console.log(`[DataSeeder] ${entityType} store already has ${existing.length} items, skipping seed`); - return; - } - - console.log(`[DataSeeder] ${entityType} store is empty, fetching from repository...`); - - // Fetch from repository (Mock JSON or backend API) - const data = await repository.fetchAll(); - - console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`); - - // Save each entity to IndexedDB - // Note: Entities from repository should already have syncStatus='synced' - for (const entity of data) { - await service.save(entity); - } - - console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`); - } -} diff --git a/src/workers/SyncManager.ts b/src/workers/SyncManager.ts index 2ec2b5f..c36a348 100644 --- a/src/workers/SyncManager.ts +++ b/src/workers/SyncManager.ts @@ -1,33 +1,41 @@ -import { IEventBus } from '../types/CalendarTypes'; +import { IEventBus, EntityType, ISync } from '../types/CalendarTypes'; import { CoreEvents } from '../constants/CoreEvents'; -import { IAuditEntry } from '../types/AuditTypes'; -import { AuditService } from '../storage/audit/AuditService'; +import { OperationQueue } from '../storage/OperationQueue'; +import { IQueueOperation } from '../storage/IndexedDBService'; +import { IndexedDBService } from '../storage/IndexedDBService'; import { IApiRepository } from '../repositories/IApiRepository'; +import { IEntityService } from '../storage/IEntityService'; /** * SyncManager - Background sync worker - * Syncs audit entries with backend API when online + * Processes operation queue and syncs with API when online * - * NEW ARCHITECTURE: - * - Listens to AUDIT_LOGGED events (triggered after AuditService saves) - * - Polls AuditService for pending audit entries - * - Syncs audit entries to backend API - * - Marks audit entries as synced when successful + * GENERIC ARCHITECTURE: + * - Handles all entity types (Event, Booking, Customer, Resource) + * - Routes operations based on IQueueOperation.dataEntity.typename + * - Uses IApiRepository pattern for type-safe API calls + * - Uses IEntityService polymorphism for sync status management * - * EVENT CHAIN: - * Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager + * POLYMORFI DESIGN: + * - Services implement IEntityService interface + * - SyncManager uses Array.find() for service lookup (simple, only 4 entities) + * - Services encapsulate sync status manipulation (markAsSynced, markAsError) + * - SyncManager does NOT manipulate entity.syncStatus directly + * - Open/Closed Principle: Adding new entity requires only DI registration * * Features: * - Monitors online/offline status - * - Processes pending audits with FIFO order + * - Processes queue with FIFO order * - Exponential backoff retry logic * - Updates syncStatus in IndexedDB after successful sync * - Emits sync events for UI feedback */ export class SyncManager { private eventBus: IEventBus; - private auditService: AuditService; - private auditApiRepository: IApiRepository; + private queue: OperationQueue; + private indexedDB: IndexedDBService; + private repositories: Map>; + private entityServices: IEntityService[]; private isOnline: boolean = navigator.onLine; private isSyncing: boolean = false; @@ -35,35 +43,26 @@ export class SyncManager { private maxRetries: number = 5; private intervalId: number | null = null; - // Track retry counts per audit entry (in memory) - private retryCounts: Map = new Map(); - constructor( eventBus: IEventBus, - auditService: AuditService, - auditApiRepository: IApiRepository + queue: OperationQueue, + indexedDB: IndexedDBService, + apiRepositories: IApiRepository[], + entityServices: IEntityService[] ) { this.eventBus = eventBus; - this.auditService = auditService; - this.auditApiRepository = auditApiRepository; + this.queue = queue; + this.indexedDB = indexedDB; + this.entityServices = entityServices; + + // Build map: EntityType → IApiRepository + this.repositories = new Map( + apiRepositories.map(repo => [repo.entityType, repo]) + ); this.setupNetworkListeners(); - this.setupAuditListener(); this.startSync(); - console.log('SyncManager initialized - listening for AUDIT_LOGGED events'); - } - - /** - * Setup listener for AUDIT_LOGGED events - * Triggers immediate sync attempt when new audit entry is saved - */ - private setupAuditListener(): void { - this.eventBus.on(CoreEvents.AUDIT_LOGGED, () => { - // New audit entry saved - try to sync if online - if (this.isOnline && !this.isSyncing) { - this.processPendingAudits(); - } - }); + console.log(`SyncManager initialized with ${apiRepositories.length} entity repositories and ${entityServices.length} entity services`); } /** @@ -100,11 +99,11 @@ export class SyncManager { console.log('SyncManager: Starting background sync'); // Process immediately - this.processPendingAudits(); + this.processQueue(); // Then poll every syncInterval this.intervalId = window.setInterval(() => { - this.processPendingAudits(); + this.processQueue(); }, this.syncInterval); } @@ -120,10 +119,10 @@ export class SyncManager { } /** - * Process pending audit entries - * Fetches from AuditService and syncs to backend + * Process operation queue + * Sends pending operations to API */ - private async processPendingAudits(): Promise { + private async processQueue(): Promise { // Don't sync if offline if (!this.isOnline) { return; @@ -134,33 +133,31 @@ export class SyncManager { return; } + // Check if queue is empty + if (await this.queue.isEmpty()) { + return; + } + this.isSyncing = true; try { - const pendingAudits = await this.auditService.getPendingAudits(); - - if (pendingAudits.length === 0) { - this.isSyncing = false; - return; - } + const operations = await this.queue.getAll(); this.eventBus.emit(CoreEvents.SYNC_STARTED, { - operationCount: pendingAudits.length + operationCount: operations.length }); - // Process audits one by one (FIFO - oldest first by timestamp) - const sortedAudits = pendingAudits.sort((a, b) => a.timestamp - b.timestamp); - - for (const audit of sortedAudits) { - await this.processAuditEntry(audit); + // Process operations one by one (FIFO) + for (const operation of operations) { + await this.processOperation(operation); } this.eventBus.emit(CoreEvents.SYNC_COMPLETED, { - operationCount: pendingAudits.length + operationCount: operations.length }); } catch (error) { - console.error('SyncManager: Audit processing error:', error); + console.error('SyncManager: Queue processing error:', error); this.eventBus.emit(CoreEvents.SYNC_FAILED, { error: error instanceof Error ? error.message : 'Unknown error' }); @@ -170,47 +167,106 @@ export class SyncManager { } /** - * Process a single audit entry - * Sends to backend API and marks as synced + * Process a single operation + * Generic - routes to correct API repository based on entity type */ - private async processAuditEntry(audit: IAuditEntry): Promise { - const retryCount = this.retryCounts.get(audit.id) || 0; - + private async processOperation(operation: IQueueOperation): Promise { // Check if max retries exceeded - if (retryCount >= this.maxRetries) { - console.error(`SyncManager: Max retries exceeded for audit ${audit.id}`); - await this.auditService.markAsError(audit.id); - this.retryCounts.delete(audit.id); + if (operation.retryCount >= this.maxRetries) { + console.error(`SyncManager: Max retries exceeded for operation ${operation.id}`, operation); + await this.queue.remove(operation.id); + await this.markEntityAsError(operation.dataEntity.typename, operation.entityId); + return; + } + + // Get the appropriate API repository for this entity type + const repository = this.repositories.get(operation.dataEntity.typename); + if (!repository) { + console.error(`SyncManager: No repository found for entity type ${operation.dataEntity.typename}`); + await this.queue.remove(operation.id); return; } try { - // Send audit entry to backend - await this.auditApiRepository.sendCreate(audit); + // Send to API based on operation type + switch (operation.type) { + case 'create': + await repository.sendCreate(operation.dataEntity.data); + break; - // Success - mark as synced and clear retry count - await this.auditService.markAsSynced(audit.id); - this.retryCounts.delete(audit.id); + case 'update': + await repository.sendUpdate(operation.entityId, operation.dataEntity.data); + break; - console.log(`SyncManager: Successfully synced audit ${audit.id} (${audit.entityType}:${audit.operation})`); + case 'delete': + await repository.sendDelete(operation.entityId); + break; + + default: + console.error(`SyncManager: Unknown operation type ${operation.type}`); + await this.queue.remove(operation.id); + return; + } + + // Success - remove from queue and mark as synced + await this.queue.remove(operation.id); + await this.markEntityAsSynced(operation.dataEntity.typename, operation.entityId); + + console.log(`SyncManager: Successfully synced ${operation.dataEntity.typename} operation ${operation.id}`); } catch (error) { - console.error(`SyncManager: Failed to sync audit ${audit.id}:`, error); + console.error(`SyncManager: Failed to sync operation ${operation.id}:`, error); // Increment retry count - this.retryCounts.set(audit.id, retryCount + 1); + await this.queue.incrementRetryCount(operation.id); // Calculate backoff delay - const backoffDelay = this.calculateBackoff(retryCount + 1); + const backoffDelay = this.calculateBackoff(operation.retryCount + 1); this.eventBus.emit(CoreEvents.SYNC_RETRY, { - auditId: audit.id, - retryCount: retryCount + 1, + operationId: operation.id, + retryCount: operation.retryCount + 1, nextRetryIn: backoffDelay }); } } + /** + * Mark entity as synced in IndexedDB + * Uses polymorphism - delegates to IEntityService.markAsSynced() + */ + private async markEntityAsSynced(entityType: EntityType, entityId: string): Promise { + try { + const service = this.entityServices.find(s => s.entityType === entityType); + if (!service) { + console.error(`SyncManager: No service found for entity type ${entityType}`); + return; + } + + await service.markAsSynced(entityId); + } catch (error) { + console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as synced:`, error); + } + } + + /** + * Mark entity as error in IndexedDB + * Uses polymorphism - delegates to IEntityService.markAsError() + */ + private async markEntityAsError(entityType: EntityType, entityId: string): Promise { + try { + const service = this.entityServices.find(s => s.entityType === entityType); + if (!service) { + console.error(`SyncManager: No service found for entity type ${entityType}`); + return; + } + + await service.markAsError(entityId); + } catch (error) { + console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as error:`, error); + } + } + /** * Calculate exponential backoff delay * @param retryCount Current retry count @@ -230,7 +286,7 @@ export class SyncManager { */ public async triggerManualSync(): Promise { console.log('SyncManager: Manual sync triggered'); - await this.processPendingAudits(); + await this.processQueue(); } /** @@ -253,7 +309,6 @@ export class SyncManager { */ public destroy(): void { this.stopSync(); - this.retryCounts.clear(); // Note: We don't remove window event listeners as they're global } } diff --git a/wwwroot/css/calendar-base-css.css b/wwwroot/css/calendar-base-css.css index 10ecdc9..97d1f3c 100644 --- a/wwwroot/css/calendar-base-css.css +++ b/wwwroot/css/calendar-base-css.css @@ -41,28 +41,22 @@ --color-work-hours: rgba(255, 255, 255, 0.9); --color-current-time: #ff0000; - /* Named color palette for events */ - --b-color-red: #e53935; - --b-color-pink: #d81b60; - --b-color-magenta: #c200c2; - --b-color-purple: #8e24aa; - --b-color-violet: #5e35b1; - --b-color-deep-purple: #4527a0; - --b-color-indigo: #3949ab; - --b-color-blue: #1e88e5; - --b-color-light-blue: #03a9f4; - --b-color-cyan: #3bc9db; - --b-color-teal: #00897b; - --b-color-green: #43a047; - --b-color-light-green: #8bc34a; - --b-color-lime: #c0ca33; - --b-color-yellow: #fdd835; - --b-color-amber: #ffb300; - --b-color-orange: #fb8c00; - --b-color-deep-orange: #f4511e; - - /* Base mix for color-mix() function */ - --b-mix: #fff; + /* Event colors - Updated with month-view-expanded.html color scheme */ + --color-event-meeting: #e8f5e8; + --color-event-meeting-border: #4caf50; + --color-event-meeting-hl: #c8e6c9; + --color-event-meal: #fff8e1; + --color-event-meal-border: #ff9800; + --color-event-meal-hl: #ffe0b2; + --color-event-work: #fff8e1; + --color-event-work-border: #ff9800; + --color-event-work-hl: #ffe0b2; + --color-event-milestone: #ffebee; + --color-event-milestone-border: #f44336; + --color-event-milestone-hl: #ffcdd2; + --color-event-personal: #f3e5f5; + --color-event-personal-border: #9c27b0; + --color-event-personal-hl: #e1bee7; /* UI colors */ --color-background: #ffffff; diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css index 379f4a2..9189e8e 100644 --- a/wwwroot/css/calendar-events-css.css +++ b/wwwroot/css/calendar-events-css.css @@ -2,8 +2,6 @@ /* Event base styles */ swp-day-columns swp-event { - --b-text: var(--color-text); - position: absolute; border-radius: 3px; overflow: hidden; @@ -12,14 +10,10 @@ swp-day-columns swp-event { z-index: 10; left: 2px; right: 2px; + color: var(--color-text); font-size: 12px; padding: 4px 6px; - /* Color system using color-mix() */ - background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); - color: var(--b-text); - border-left: 4px solid var(--b-primary); - /* Enable container queries for responsive layout */ container-type: size; container-name: event; @@ -31,6 +25,43 @@ swp-day-columns swp-event { gap: 2px 4px; align-items: start; + /* Event types */ + &[data-type="meeting"] { + background: var(--color-event-meeting); + border-left: 4px solid var(--color-event-meeting-border); + color: var(--color-text); + } + + &[data-type="meal"] { + background: var(--color-event-meal); + border-left: 4px solid var(--color-event-meal-border); + color: var(--color-text); + } + + &[data-type="work"] { + background: var(--color-event-work); + border-left: 4px solid var(--color-event-work-border); + color: var(--color-text); + } + + &[data-type="milestone"] { + background: var(--color-event-milestone); + border-left: 4px solid var(--color-event-milestone-border); + color: var(--color-text); + } + + &[data-type="personal"] { + background: var(--color-event-personal); + border-left: 4px solid var(--color-event-personal-border); + color: var(--color-text); + } + + &[data-type="deadline"] { + background: var(--color-event-milestone); + border-left: 4px solid var(--color-event-milestone-border); + color: var(--color-text); + } + /* Dragging state */ &.dragging { position: absolute; @@ -41,10 +72,31 @@ swp-day-columns swp-event { width: auto; } - /* Hover state */ - &:hover { - background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)); + /* Hover state - highlight colors */ + &:hover[data-type="meeting"] { + background: var(--color-event-meeting-hl); } + + &:hover[data-type="meal"] { + background: var(--color-event-meal-hl); + } + + &:hover[data-type="work"] { + background: var(--color-event-work-hl); + } + + &:hover[data-type="milestone"] { + background: var(--color-event-milestone-hl); + } + + &:hover[data-type="personal"] { + background: var(--color-event-personal-hl); + } + + &:hover[data-type="deadline"] { + background: var(--color-event-milestone-hl); + } + } swp-day-columns swp-event:hover { @@ -166,14 +218,10 @@ swp-multi-day-event { white-space: nowrap; text-overflow: ellipsis; - /* Color system using color-mix() */ - --b-text: var(--color-text); - background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); - color: var(--b-text); - border-left: 4px solid var(--b-primary); - - &:hover { - background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)); + /* Event type colors */ + &[data-type="milestone"] { + background: var(--color-event-milestone); + color: var(--color-event-milestone-border); } /* Continuation indicators */ @@ -211,19 +259,6 @@ swp-multi-day-event { transform: translateY(-1px); box-shadow: var(--shadow-sm); } -/* All-day events */ -swp-allday-event { - --b-text: var(--color-text); - background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); - color: var(--b-text); - border-left: 4px solid var(--b-primary); - cursor: pointer; - transition: background-color 200ms ease; - - &:hover { - background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)); - } -} } /* Event creation preview */ @@ -316,23 +351,3 @@ swp-event-group swp-event { swp-allday-container swp-event.transitioning { transition: grid-area 200ms ease-out, grid-row 200ms ease-out, grid-column 200ms ease-out; } - -/* Color utility classes */ -.is-red { --b-primary: var(--b-color-red); } -.is-pink { --b-primary: var(--b-color-pink); } -.is-magenta { --b-primary: var(--b-color-magenta); } -.is-purple { --b-primary: var(--b-color-purple); } -.is-violet { --b-primary: var(--b-color-violet); } -.is-deep-purple { --b-primary: var(--b-color-deep-purple); } -.is-indigo { --b-primary: var(--b-color-indigo); } -.is-blue { --b-primary: var(--b-color-blue); } -.is-light-blue { --b-primary: var(--b-color-light-blue); } -.is-cyan { --b-primary: var(--b-color-cyan); } -.is-teal { --b-primary: var(--b-color-teal); } -.is-green { --b-primary: var(--b-color-green); } -.is-light-green { --b-primary: var(--b-color-light-green); } -.is-lime { --b-primary: var(--b-color-lime); } -.is-yellow { --b-primary: var(--b-color-yellow); } -.is-amber { --b-primary: var(--b-color-amber); } -.is-orange { --b-primary: var(--b-color-orange); } -.is-deep-orange { --b-primary: var(--b-color-deep-orange); } diff --git a/wwwroot/css/src/calendar-layout-css.css b/wwwroot/css/src/calendar-layout-css.css index 128f300..aca2407 100644 --- a/wwwroot/css/src/calendar-layout-css.css +++ b/wwwroot/css/src/calendar-layout-css.css @@ -322,20 +322,67 @@ swp-allday-container { font-size: 0.75rem; border-radius: 3px; - /* Color system using color-mix() */ - --b-text: var(--color-text); - background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); - color: var(--b-text); - border-left: 4px solid var(--b-primary); + /* Event type colors - normal state */ + &[data-type="meeting"] { + background: var(--color-event-meeting); + color: var(--color-text); + } + + &[data-type="meal"] { + background: var(--color-event-meal); + color: var(--color-text); + } + + &[data-type="work"] { + background: var(--color-event-work); + color: var(--color-text); + } + + &[data-type="milestone"] { + background: var(--color-event-milestone); + color: var(--color-text); + } + + &[data-type="personal"] { + background: var(--color-event-personal); + color: var(--color-text); + } + + &[data-type="deadline"] { + background: var(--color-event-milestone); + color: var(--color-text); + } /* Dragging state */ &.dragging { opacity: 1; } - /* Highlight state */ + /* Highlight state for all event types */ &.highlight { - background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)) !important; + &[data-type="meeting"] { + background: var(--color-event-meeting-hl) !important; + } + + &[data-type="meal"] { + background: var(--color-event-meal-hl) !important; + } + + &[data-type="work"] { + background: var(--color-event-work-hl) !important; + } + + &[data-type="milestone"] { + background: var(--color-event-milestone-hl) !important; + } + + &[data-type="personal"] { + background: var(--color-event-personal-hl) !important; + } + + &[data-type="deadline"] { + background: var(--color-event-milestone-hl) !important; + } } /* Overflow indicator styling */ diff --git a/wwwroot/data/bookings.json b/wwwroot/data/bookings.json new file mode 100644 index 0000000..a4c0eec --- /dev/null +++ b/wwwroot/data/bookings.json @@ -0,0 +1,306 @@ +[ + { + "id": "BOOK001", + "customerId": "CUST001", + "status": "arrived", + "createdAt": "2025-08-05T08:00:00Z", + "services": [ + { + "serviceId": "SRV001", + "serviceName": "Klipning og styling", + "baseDuration": 60, + "basePrice": 500, + "customPrice": 500, + "resourceId": "EMP001" + } + ], + "totalPrice": 500, + "notes": "Kunde ønsker lidt kortere" + }, + { + "id": "BOOK002", + "customerId": "CUST002", + "status": "paid", + "createdAt": "2025-08-05T09:00:00Z", + "services": [ + { + "serviceId": "SRV002", + "serviceName": "Hårvask", + "baseDuration": 30, + "basePrice": 100, + "customPrice": 100, + "resourceId": "STUDENT001" + }, + { + "serviceId": "SRV003", + "serviceName": "Bundfarve", + "baseDuration": 90, + "basePrice": 800, + "customPrice": 800, + "resourceId": "EMP001" + } + ], + "totalPrice": 900, + "notes": "Split booking: Elev laver hårvask, master laver farve" + }, + { + "id": "BOOK003", + "customerId": "CUST003", + "status": "created", + "createdAt": "2025-08-05T07:00:00Z", + "services": [ + { + "serviceId": "SRV004A", + "serviceName": "Bryllupsfrisure - Del 1", + "baseDuration": 60, + "basePrice": 750, + "customPrice": 750, + "resourceId": "EMP001" + }, + { + "serviceId": "SRV004B", + "serviceName": "Bryllupsfrisure - Del 2", + "baseDuration": 60, + "basePrice": 750, + "customPrice": 750, + "resourceId": "EMP002" + } + ], + "totalPrice": 1500, + "notes": "Equal-split: To master stylister arbejder sammen" + }, + { + "id": "BOOK004", + "customerId": "CUST004", + "status": "arrived", + "createdAt": "2025-08-05T10:00:00Z", + "services": [ + { + "serviceId": "SRV005", + "serviceName": "Herreklipning", + "baseDuration": 30, + "basePrice": 350, + "customPrice": 350, + "resourceId": "EMP003" + } + ], + "totalPrice": 350 + }, + { + "id": "BOOK005", + "customerId": "CUST005", + "status": "paid", + "createdAt": "2025-08-05T11:00:00Z", + "services": [ + { + "serviceId": "SRV006", + "serviceName": "Balayage langt hår", + "baseDuration": 120, + "basePrice": 1200, + "customPrice": 1200, + "resourceId": "EMP002" + } + ], + "totalPrice": 1200, + "notes": "Kunde ønsker naturlig blond tone" + }, + { + "id": "BOOK006", + "customerId": "CUST006", + "status": "created", + "createdAt": "2025-08-06T08:00:00Z", + "services": [ + { + "serviceId": "SRV007", + "serviceName": "Permanent", + "baseDuration": 90, + "basePrice": 900, + "customPrice": 900, + "resourceId": "EMP004" + } + ], + "totalPrice": 900 + }, + { + "id": "BOOK007", + "customerId": "CUST007", + "status": "arrived", + "createdAt": "2025-08-06T09:00:00Z", + "services": [ + { + "serviceId": "SRV008", + "serviceName": "Highlights", + "baseDuration": 90, + "basePrice": 850, + "customPrice": 850, + "resourceId": "EMP001" + }, + { + "serviceId": "SRV009", + "serviceName": "Styling", + "baseDuration": 30, + "basePrice": 200, + "customPrice": 200, + "resourceId": "EMP001" + } + ], + "totalPrice": 1050, + "notes": "Highlights + styling samme stylist" + }, + { + "id": "BOOK008", + "customerId": "CUST008", + "status": "paid", + "createdAt": "2025-08-06T10:00:00Z", + "services": [ + { + "serviceId": "SRV010", + "serviceName": "Klipning", + "baseDuration": 45, + "basePrice": 450, + "customPrice": 450, + "resourceId": "EMP004" + } + ], + "totalPrice": 450 + }, + { + "id": "BOOK009", + "customerId": "CUST001", + "status": "created", + "createdAt": "2025-08-07T08:00:00Z", + "services": [ + { + "serviceId": "SRV011", + "serviceName": "Farve behandling", + "baseDuration": 120, + "basePrice": 950, + "customPrice": 950, + "resourceId": "EMP002" + } + ], + "totalPrice": 950 + }, + { + "id": "BOOK010", + "customerId": "CUST002", + "status": "arrived", + "createdAt": "2025-08-07T09:00:00Z", + "services": [ + { + "serviceId": "SRV012", + "serviceName": "Skæg trimning", + "baseDuration": 20, + "basePrice": 200, + "customPrice": 200, + "resourceId": "EMP003" + } + ], + "totalPrice": 200 + }, + { + "id": "BOOK011", + "customerId": "CUST003", + "status": "paid", + "createdAt": "2025-08-07T10:00:00Z", + "services": [ + { + "serviceId": "SRV002", + "serviceName": "Hårvask", + "baseDuration": 30, + "basePrice": 100, + "customPrice": 100, + "resourceId": "STUDENT002" + }, + { + "serviceId": "SRV013", + "serviceName": "Ombré", + "baseDuration": 100, + "basePrice": 1100, + "customPrice": 1100, + "resourceId": "EMP002" + } + ], + "totalPrice": 1200, + "notes": "Split booking: Student hårvask, master ombré" + }, + { + "id": "BOOK012", + "customerId": "CUST004", + "status": "created", + "createdAt": "2025-08-08T08:00:00Z", + "services": [ + { + "serviceId": "SRV014", + "serviceName": "Føntørring", + "baseDuration": 30, + "basePrice": 250, + "customPrice": 250, + "resourceId": "STUDENT001" + } + ], + "totalPrice": 250 + }, + { + "id": "BOOK013", + "customerId": "CUST005", + "status": "arrived", + "createdAt": "2025-08-08T09:00:00Z", + "services": [ + { + "serviceId": "SRV015", + "serviceName": "Opsætning", + "baseDuration": 60, + "basePrice": 700, + "customPrice": 700, + "resourceId": "EMP004" + } + ], + "totalPrice": 700, + "notes": "Fest opsætning" + }, + { + "id": "BOOK014", + "customerId": "CUST006", + "status": "created", + "createdAt": "2025-08-09T08:00:00Z", + "services": [ + { + "serviceId": "SRV016A", + "serviceName": "Ekstensions - Del 1", + "baseDuration": 90, + "basePrice": 1250, + "customPrice": 1250, + "resourceId": "EMP001" + }, + { + "serviceId": "SRV016B", + "serviceName": "Ekstensions - Del 2", + "baseDuration": 90, + "basePrice": 1250, + "customPrice": 1250, + "resourceId": "EMP004" + } + ], + "totalPrice": 2500, + "notes": "Equal-split: To stylister arbejder sammen om extensions" + }, + { + "id": "BOOK015", + "customerId": "CUST007", + "status": "noshow", + "createdAt": "2025-08-09T09:00:00Z", + "services": [ + { + "serviceId": "SRV001", + "serviceName": "Klipning og styling", + "baseDuration": 60, + "basePrice": 500, + "customPrice": 500, + "resourceId": "EMP002" + } + ], + "totalPrice": 500, + "notes": "Kunde mødte ikke op" + } +] diff --git a/wwwroot/data/mock-customers.json b/wwwroot/data/customers.json similarity index 100% rename from wwwroot/data/mock-customers.json rename to wwwroot/data/customers.json diff --git a/wwwroot/data/events.json b/wwwroot/data/events.json new file mode 100644 index 0000000..498cbe5 --- /dev/null +++ b/wwwroot/data/events.json @@ -0,0 +1,485 @@ +[ + { + "id": "EVT001", + "title": "Sofie Nielsen - Klipning og styling", + "start": "2025-08-05T10:00:00Z", + "end": "2025-08-05T11:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK001", + "resourceId": "EMP001", + "customerId": "CUST001" + }, + { + "id": "EVT002", + "title": "Emma Andersen - Hårvask", + "start": "2025-08-05T11:00:00Z", + "end": "2025-08-05T11:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK002", + "resourceId": "STUDENT001", + "customerId": "CUST002" + }, + { + "id": "EVT003", + "title": "Emma Andersen - Bundfarve", + "start": "2025-08-05T11:30:00Z", + "end": "2025-08-05T13:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK002", + "resourceId": "EMP001", + "customerId": "CUST002" + }, + { + "id": "EVT004", + "title": "Freja Christensen - Bryllupsfrisure (Camilla)", + "start": "2025-08-05T08:00:00Z", + "end": "2025-08-05T10:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK003", + "resourceId": "EMP001", + "customerId": "CUST003", + "metadata": { + "note": "To stylister arbejder sammen" + } + }, + { + "id": "EVT005", + "title": "Freja Christensen - Bryllupsfrisure (Isabella)", + "start": "2025-08-05T08:00:00Z", + "end": "2025-08-05T10:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK003", + "resourceId": "EMP002", + "customerId": "CUST003", + "metadata": { + "note": "To stylister arbejder sammen" + } + }, + { + "id": "EVT006", + "title": "Laura Pedersen - Herreklipning", + "start": "2025-08-05T11:00:00Z", + "end": "2025-08-05T11:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK004", + "resourceId": "EMP003", + "customerId": "CUST004" + }, + { + "id": "EVT007", + "title": "Ida Larsen - Balayage langt hår", + "start": "2025-08-05T13:00:00Z", + "end": "2025-08-05T15:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK005", + "resourceId": "EMP002", + "customerId": "CUST005" + }, + { + "id": "EVT008", + "title": "Frokostpause", + "start": "2025-08-05T12:00:00Z", + "end": "2025-08-05T12:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP003" + }, + { + "id": "EVT009", + "title": "Caroline Jensen - Permanent", + "start": "2025-08-06T09:00:00Z", + "end": "2025-08-06T10:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK006", + "resourceId": "EMP004", + "customerId": "CUST006" + }, + { + "id": "EVT010", + "title": "Mathilde Hansen - Highlights", + "start": "2025-08-06T10:00:00Z", + "end": "2025-08-06T11:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK007", + "resourceId": "EMP001", + "customerId": "CUST007" + }, + { + "id": "EVT011", + "title": "Mathilde Hansen - Styling", + "start": "2025-08-06T11:30:00Z", + "end": "2025-08-06T12:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK007", + "resourceId": "EMP001", + "customerId": "CUST007" + }, + { + "id": "EVT012", + "title": "Olivia Sørensen - Klipning", + "start": "2025-08-06T13:00:00Z", + "end": "2025-08-06T13:45:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK008", + "resourceId": "EMP004", + "customerId": "CUST008" + }, + { + "id": "EVT013", + "title": "Team møde - Salgsmål", + "start": "2025-08-06T08:00:00Z", + "end": "2025-08-06T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP001", + "metadata": { + "attendees": ["EMP001", "EMP002", "EMP003", "EMP004"] + } + }, + { + "id": "EVT014", + "title": "Frokostpause", + "start": "2025-08-06T12:00:00Z", + "end": "2025-08-06T12:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP002" + }, + { + "id": "EVT015", + "title": "Sofie Nielsen - Farve behandling", + "start": "2025-08-07T10:00:00Z", + "end": "2025-08-07T12:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK009", + "resourceId": "EMP002", + "customerId": "CUST001" + }, + { + "id": "EVT016", + "title": "Emma Andersen - Skæg trimning", + "start": "2025-08-07T09:00:00Z", + "end": "2025-08-07T09:20:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK010", + "resourceId": "EMP003", + "customerId": "CUST002" + }, + { + "id": "EVT017", + "title": "Freja Christensen - Hårvask", + "start": "2025-08-07T11:00:00Z", + "end": "2025-08-07T11:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK011", + "resourceId": "STUDENT002", + "customerId": "CUST003" + }, + { + "id": "EVT018", + "title": "Freja Christensen - Ombré", + "start": "2025-08-07T11:30:00Z", + "end": "2025-08-07T13:10:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK011", + "resourceId": "EMP002", + "customerId": "CUST003" + }, + { + "id": "EVT019", + "title": "Frokostpause", + "start": "2025-08-07T12:00:00Z", + "end": "2025-08-07T12:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP001" + }, + { + "id": "EVT020", + "title": "Laura Pedersen - Føntørring", + "start": "2025-08-08T09:00:00Z", + "end": "2025-08-08T09:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK012", + "resourceId": "STUDENT001", + "customerId": "CUST004" + }, + { + "id": "EVT021", + "title": "Ida Larsen - Opsætning", + "start": "2025-08-08T10:00:00Z", + "end": "2025-08-08T11:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK013", + "resourceId": "EMP004", + "customerId": "CUST005" + }, + { + "id": "EVT022", + "title": "Produktleverance møde", + "start": "2025-08-08T08:00:00Z", + "end": "2025-08-08T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP001", + "metadata": { + "attendees": ["EMP001", "EMP004"] + } + }, + { + "id": "EVT023", + "title": "Frokostpause", + "start": "2025-08-08T12:00:00Z", + "end": "2025-08-08T12:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP004" + }, + { + "id": "EVT024", + "title": "Caroline Jensen - Ekstensions (Camilla)", + "start": "2025-08-09T09:00:00Z", + "end": "2025-08-09T12:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK014", + "resourceId": "EMP001", + "customerId": "CUST006", + "metadata": { + "note": "To stylister arbejder sammen" + } + }, + { + "id": "EVT025", + "title": "Caroline Jensen - Ekstensions (Viktor)", + "start": "2025-08-09T09:00:00Z", + "end": "2025-08-09T12:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK014", + "resourceId": "EMP004", + "customerId": "CUST006", + "metadata": { + "note": "To stylister arbejder sammen" + } + }, + { + "id": "EVT026", + "title": "Mathilde Hansen - Klipning og styling", + "start": "2025-08-09T10:00:00Z", + "end": "2025-08-09T11:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "bookingId": "BOOK015", + "resourceId": "EMP002", + "customerId": "CUST007", + "metadata": { + "note": "NOSHOW - kunde mødte ikke op" + } + }, + { + "id": "EVT027", + "title": "Ferie - Spanien", + "start": "2025-08-10T00:00:00Z", + "end": "2025-08-17T23:59:59Z", + "type": "vacation", + "allDay": true, + "syncStatus": "synced", + "resourceId": "EMP003", + "metadata": { + "destination": "Mallorca" + } + }, + { + "id": "EVT028", + "title": "Frokostpause", + "start": "2025-08-09T12:00:00Z", + "end": "2025-08-09T12:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP002" + }, + { + "id": "EVT029", + "title": "Kaffepause", + "start": "2025-08-05T14:00:00Z", + "end": "2025-08-05T14:15:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP004" + }, + { + "id": "EVT030", + "title": "Kursus - Nye farvningsteknikker", + "start": "2025-08-11T09:00:00Z", + "end": "2025-08-11T16:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP001", + "metadata": { + "location": "København", + "type": "external_course" + } + }, + { + "id": "EVT031", + "title": "Supervision - Elev", + "start": "2025-08-05T15:00:00Z", + "end": "2025-08-05T15:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP001", + "metadata": { + "attendees": ["EMP001", "STUDENT001"] + } + }, + { + "id": "EVT032", + "title": "Aftensmad pause", + "start": "2025-08-06T17:00:00Z", + "end": "2025-08-06T17:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP001" + }, + { + "id": "EVT033", + "title": "Supervision - Elev", + "start": "2025-08-07T15:00:00Z", + "end": "2025-08-07T15:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP002", + "metadata": { + "attendees": ["EMP002", "STUDENT002"] + } + }, + { + "id": "EVT034", + "title": "Rengøring af arbejdsstation", + "start": "2025-08-08T16:00:00Z", + "end": "2025-08-08T16:30:00Z", + "type": "blocked", + "allDay": false, + "syncStatus": "synced", + "resourceId": "STUDENT001" + }, + { + "id": "EVT035", + "title": "Rengøring af arbejdsstation", + "start": "2025-08-08T16:00:00Z", + "end": "2025-08-08T16:30:00Z", + "type": "blocked", + "allDay": false, + "syncStatus": "synced", + "resourceId": "STUDENT002" + }, + { + "id": "EVT036", + "title": "Leverandør møde", + "start": "2025-08-09T14:00:00Z", + "end": "2025-08-09T15:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP004", + "metadata": { + "attendees": ["EMP004"] + } + }, + { + "id": "EVT037", + "title": "Sygedag", + "start": "2025-08-12T00:00:00Z", + "end": "2025-08-12T23:59:59Z", + "type": "vacation", + "allDay": true, + "syncStatus": "synced", + "resourceId": "STUDENT001", + "metadata": { + "reason": "sick_leave" + } + }, + { + "id": "EVT038", + "title": "Frokostpause", + "start": "2025-08-05T12:00:00Z", + "end": "2025-08-05T12:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "STUDENT001" + }, + { + "id": "EVT039", + "title": "Frokostpause", + "start": "2025-08-05T12:00:00Z", + "end": "2025-08-05T12:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "resourceId": "STUDENT002" + }, + { + "id": "EVT040", + "title": "Morgen briefing", + "start": "2025-08-05T08:30:00Z", + "end": "2025-08-05T08:45:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "resourceId": "EMP004", + "metadata": { + "attendees": ["EMP001", "EMP002", "EMP003", "EMP004", "STUDENT001", "STUDENT002"] + } + } +] diff --git a/wwwroot/data/mock-bookings.json b/wwwroot/data/mock-bookings.json deleted file mode 100644 index 985bda0..0000000 --- a/wwwroot/data/mock-bookings.json +++ /dev/null @@ -1,514 +0,0 @@ -[ - { - "id": "BOOK001", - "customerId": "CUST001", - "status": "arrived", - "createdAt": "2025-08-05T08:00:00Z", - "services": [ - { - "serviceId": "SRV001", - "serviceName": "Klipning og styling", - "baseDuration": 60, - "basePrice": 500, - "customPrice": 500, - "resourceId": "EMP001" - } - ], - "totalPrice": 500, - "notes": "Kunde ønsker lidt kortere" - }, - { - "id": "BOOK002", - "customerId": "CUST002", - "status": "paid", - "createdAt": "2025-08-05T09:00:00Z", - "services": [ - { - "serviceId": "SRV002", - "serviceName": "Hårvask", - "baseDuration": 30, - "basePrice": 100, - "customPrice": 100, - "resourceId": "STUDENT001" - }, - { - "serviceId": "SRV003", - "serviceName": "Bundfarve", - "baseDuration": 90, - "basePrice": 800, - "customPrice": 800, - "resourceId": "EMP001" - } - ], - "totalPrice": 900, - "notes": "Split booking: Elev laver hårvask, master laver farve" - }, - { - "id": "BOOK003", - "customerId": "CUST003", - "status": "created", - "createdAt": "2025-08-05T07:00:00Z", - "services": [ - { - "serviceId": "SRV004A", - "serviceName": "Bryllupsfrisure - Del 1", - "baseDuration": 60, - "basePrice": 750, - "customPrice": 750, - "resourceId": "EMP001" - }, - { - "serviceId": "SRV004B", - "serviceName": "Bryllupsfrisure - Del 2", - "baseDuration": 60, - "basePrice": 750, - "customPrice": 750, - "resourceId": "EMP002" - } - ], - "totalPrice": 1500, - "notes": "Equal-split: To master stylister arbejder sammen" - }, - { - "id": "BOOK004", - "customerId": "CUST004", - "status": "arrived", - "createdAt": "2025-08-05T10:00:00Z", - "services": [ - { - "serviceId": "SRV005", - "serviceName": "Herreklipning", - "baseDuration": 30, - "basePrice": 350, - "customPrice": 350, - "resourceId": "EMP003" - } - ], - "totalPrice": 350 - }, - { - "id": "BOOK005", - "customerId": "CUST005", - "status": "paid", - "createdAt": "2025-08-05T11:00:00Z", - "services": [ - { - "serviceId": "SRV006", - "serviceName": "Balayage langt hår", - "baseDuration": 120, - "basePrice": 1200, - "customPrice": 1200, - "resourceId": "EMP002" - } - ], - "totalPrice": 1200, - "notes": "Kunde ønsker naturlig blond tone" - }, - { - "id": "BOOK006", - "customerId": "CUST006", - "status": "created", - "createdAt": "2025-08-06T08:00:00Z", - "services": [ - { - "serviceId": "SRV007", - "serviceName": "Permanent", - "baseDuration": 90, - "basePrice": 900, - "customPrice": 900, - "resourceId": "EMP004" - } - ], - "totalPrice": 900 - }, - { - "id": "BOOK007", - "customerId": "CUST007", - "status": "arrived", - "createdAt": "2025-08-06T09:00:00Z", - "services": [ - { - "serviceId": "SRV008", - "serviceName": "Highlights", - "baseDuration": 90, - "basePrice": 850, - "customPrice": 850, - "resourceId": "EMP001" - }, - { - "serviceId": "SRV009", - "serviceName": "Styling", - "baseDuration": 30, - "basePrice": 200, - "customPrice": 200, - "resourceId": "EMP001" - } - ], - "totalPrice": 1050, - "notes": "Highlights + styling samme stylist" - }, - { - "id": "BOOK008", - "customerId": "CUST008", - "status": "paid", - "createdAt": "2025-08-06T10:00:00Z", - "services": [ - { - "serviceId": "SRV010", - "serviceName": "Klipning", - "baseDuration": 45, - "basePrice": 450, - "customPrice": 450, - "resourceId": "EMP004" - } - ], - "totalPrice": 450 - }, - { - "id": "BOOK009", - "customerId": "CUST001", - "status": "created", - "createdAt": "2025-08-07T08:00:00Z", - "services": [ - { - "serviceId": "SRV011", - "serviceName": "Farve behandling", - "baseDuration": 120, - "basePrice": 950, - "customPrice": 950, - "resourceId": "EMP002" - } - ], - "totalPrice": 950 - }, - { - "id": "BOOK010", - "customerId": "CUST002", - "status": "arrived", - "createdAt": "2025-08-07T09:00:00Z", - "services": [ - { - "serviceId": "SRV012", - "serviceName": "Skæg trimning", - "baseDuration": 20, - "basePrice": 200, - "customPrice": 200, - "resourceId": "EMP003" - } - ], - "totalPrice": 200 - }, - { - "id": "BOOK011", - "customerId": "CUST003", - "status": "paid", - "createdAt": "2025-08-07T10:00:00Z", - "services": [ - { - "serviceId": "SRV002", - "serviceName": "Hårvask", - "baseDuration": 30, - "basePrice": 100, - "customPrice": 100, - "resourceId": "STUDENT002" - }, - { - "serviceId": "SRV013", - "serviceName": "Ombré", - "baseDuration": 100, - "basePrice": 1100, - "customPrice": 1100, - "resourceId": "EMP002" - } - ], - "totalPrice": 1200, - "notes": "Split booking: Student hårvask, master ombré" - }, - { - "id": "BOOK012", - "customerId": "CUST004", - "status": "created", - "createdAt": "2025-08-08T08:00:00Z", - "services": [ - { - "serviceId": "SRV014", - "serviceName": "Føntørring", - "baseDuration": 30, - "basePrice": 250, - "customPrice": 250, - "resourceId": "STUDENT001" - } - ], - "totalPrice": 250 - }, - { - "id": "BOOK013", - "customerId": "CUST005", - "status": "arrived", - "createdAt": "2025-08-08T09:00:00Z", - "services": [ - { - "serviceId": "SRV015", - "serviceName": "Opsætning", - "baseDuration": 60, - "basePrice": 700, - "customPrice": 700, - "resourceId": "EMP004" - } - ], - "totalPrice": 700, - "notes": "Fest opsætning" - }, - { - "id": "BOOK014", - "customerId": "CUST006", - "status": "created", - "createdAt": "2025-08-09T08:00:00Z", - "services": [ - { - "serviceId": "SRV016A", - "serviceName": "Ekstensions - Del 1", - "baseDuration": 90, - "basePrice": 1250, - "customPrice": 1250, - "resourceId": "EMP001" - }, - { - "serviceId": "SRV016B", - "serviceName": "Ekstensions - Del 2", - "baseDuration": 90, - "basePrice": 1250, - "customPrice": 1250, - "resourceId": "EMP004" - } - ], - "totalPrice": 2500, - "notes": "Equal-split: To stylister arbejder sammen om extensions" - }, - { - "id": "BOOK015", - "customerId": "CUST007", - "status": "noshow", - "createdAt": "2025-08-09T09:00:00Z", - "services": [ - { - "serviceId": "SRV001", - "serviceName": "Klipning og styling", - "baseDuration": 60, - "basePrice": 500, - "customPrice": 500, - "resourceId": "EMP002" - } - ], - "totalPrice": 500, - "notes": "Kunde mødte ikke op" - }, - { - "id": "BOOK-NOV22-001", - "customerId": "CUST001", - "status": "arrived", - "createdAt": "2025-11-20T10:00:00Z", - "services": [ - { "serviceId": "SRV-WASH", "serviceName": "Hårvask", "baseDuration": 30, "basePrice": 100, "resourceId": "STUDENT001" }, - { "serviceId": "SRV-BAL", "serviceName": "Balayage", "baseDuration": 90, "basePrice": 1200, "resourceId": "EMP001" } - ], - "totalPrice": 1300, - "notes": "Split: Elev vasker, Camilla farver" - }, - { - "id": "BOOK-NOV22-002", - "customerId": "CUST002", - "status": "arrived", - "createdAt": "2025-11-20T11:00:00Z", - "services": [ - { "serviceId": "SRV-HERREKLIP", "serviceName": "Herreklipning", "baseDuration": 30, "basePrice": 350, "resourceId": "EMP003" } - ], - "totalPrice": 350 - }, - { - "id": "BOOK-NOV22-003", - "customerId": "CUST003", - "status": "created", - "createdAt": "2025-11-20T12:00:00Z", - "services": [ - { "serviceId": "SRV-FARVE", "serviceName": "Farvning", "baseDuration": 120, "basePrice": 900, "resourceId": "EMP002" } - ], - "totalPrice": 900 - }, - { - "id": "BOOK-NOV22-004", - "customerId": "CUST004", - "status": "arrived", - "createdAt": "2025-11-20T13:00:00Z", - "services": [ - { "serviceId": "SRV-KLIP", "serviceName": "Dameklipning", "baseDuration": 60, "basePrice": 450, "resourceId": "EMP004" } - ], - "totalPrice": 450 - }, - { - "id": "BOOK-NOV22-005", - "customerId": "CUST005", - "status": "created", - "createdAt": "2025-11-20T14:00:00Z", - "services": [ - { "serviceId": "SRV-STYLE", "serviceName": "Styling", "baseDuration": 60, "basePrice": 400, "resourceId": "EMP001" } - ], - "totalPrice": 400 - }, - { - "id": "BOOK-NOV23-001", - "customerId": "CUST006", - "status": "created", - "createdAt": "2025-11-21T09:00:00Z", - "services": [ - { "serviceId": "SRV-PERM", "serviceName": "Permanent", "baseDuration": 150, "basePrice": 1100, "resourceId": "EMP002" } - ], - "totalPrice": 1100 - }, - { - "id": "BOOK-NOV23-002", - "customerId": "CUST007", - "status": "created", - "createdAt": "2025-11-21T10:00:00Z", - "services": [ - { "serviceId": "SRV-SKAEG", "serviceName": "Skæg trimning", "baseDuration": 30, "basePrice": 200, "resourceId": "EMP003" } - ], - "totalPrice": 200 - }, - { - "id": "BOOK-NOV23-003", - "customerId": "CUST008", - "status": "created", - "createdAt": "2025-11-21T11:00:00Z", - "services": [ - { "serviceId": "SRV-WASH", "serviceName": "Hårvask", "baseDuration": 30, "basePrice": 100, "resourceId": "STUDENT002" }, - { "serviceId": "SRV-HIGH", "serviceName": "Highlights", "baseDuration": 120, "basePrice": 1000, "resourceId": "EMP001" } - ], - "totalPrice": 1100, - "notes": "Split: Elev vasker, Camilla laver highlights" - }, - { - "id": "BOOK-NOV24-001", - "customerId": "CUST001", - "status": "created", - "createdAt": "2025-11-22T08:00:00Z", - "services": [ - { "serviceId": "SRV-BRYLLUP1", "serviceName": "Bryllupsfrisure Del 1", "baseDuration": 60, "basePrice": 750, "resourceId": "EMP001" }, - { "serviceId": "SRV-BRYLLUP2", "serviceName": "Bryllupsfrisure Del 2", "baseDuration": 60, "basePrice": 750, "resourceId": "EMP002" } - ], - "totalPrice": 1500, - "notes": "Equal split: Camilla og Isabella arbejder sammen" - }, - { - "id": "BOOK-NOV24-002", - "customerId": "CUST002", - "status": "created", - "createdAt": "2025-11-22T09:00:00Z", - "services": [ - { "serviceId": "SRV-FADE", "serviceName": "Fade klipning", "baseDuration": 45, "basePrice": 400, "resourceId": "EMP003" } - ], - "totalPrice": 400 - }, - { - "id": "BOOK-NOV24-003", - "customerId": "CUST003", - "status": "created", - "createdAt": "2025-11-22T10:00:00Z", - "services": [ - { "serviceId": "SRV-KLIPVASK", "serviceName": "Klipning og vask", "baseDuration": 60, "basePrice": 500, "resourceId": "EMP004" } - ], - "totalPrice": 500 - }, - { - "id": "BOOK-NOV25-001", - "customerId": "CUST004", - "status": "created", - "createdAt": "2025-11-23T08:00:00Z", - "services": [ - { "serviceId": "SRV-BALKORT", "serviceName": "Balayage kort hår", "baseDuration": 90, "basePrice": 900, "resourceId": "EMP001" } - ], - "totalPrice": 900 - }, - { - "id": "BOOK-NOV25-002", - "customerId": "CUST005", - "status": "created", - "createdAt": "2025-11-23T09:00:00Z", - "services": [ - { "serviceId": "SRV-EXT", "serviceName": "Extensions", "baseDuration": 180, "basePrice": 2500, "resourceId": "EMP002" } - ], - "totalPrice": 2500 - }, - { - "id": "BOOK-NOV25-003", - "customerId": "CUST006", - "status": "created", - "createdAt": "2025-11-23T10:00:00Z", - "services": [ - { "serviceId": "SRV-HERRESKAEG", "serviceName": "Herreklipning + skæg", "baseDuration": 60, "basePrice": 500, "resourceId": "EMP003" } - ], - "totalPrice": 500 - }, - { - "id": "BOOK-NOV26-001", - "customerId": "CUST007", - "status": "created", - "createdAt": "2025-11-24T08:00:00Z", - "services": [ - { "serviceId": "SRV-FARVKOR", "serviceName": "Farvekorrektion", "baseDuration": 180, "basePrice": 1800, "resourceId": "EMP001" } - ], - "totalPrice": 1800 - }, - { - "id": "BOOK-NOV26-002", - "customerId": "CUST008", - "status": "created", - "createdAt": "2025-11-24T09:00:00Z", - "services": [ - { "serviceId": "SRV-KERATIN", "serviceName": "Keratinbehandling", "baseDuration": 150, "basePrice": 1400, "resourceId": "EMP002" } - ], - "totalPrice": 1400 - }, - { - "id": "BOOK-NOV26-003", - "customerId": "CUST001", - "status": "created", - "createdAt": "2025-11-24T10:00:00Z", - "services": [ - { "serviceId": "SRV-SKINFADE", "serviceName": "Skin fade", "baseDuration": 45, "basePrice": 450, "resourceId": "EMP003" } - ], - "totalPrice": 450 - }, - { - "id": "BOOK-NOV27-001", - "customerId": "CUST002", - "status": "created", - "createdAt": "2025-11-25T08:00:00Z", - "services": [ - { "serviceId": "SRV-FULLCOLOR", "serviceName": "Full color", "baseDuration": 120, "basePrice": 1000, "resourceId": "EMP001" } - ], - "totalPrice": 1000 - }, - { - "id": "BOOK-NOV27-002", - "customerId": "CUST003", - "status": "created", - "createdAt": "2025-11-25T09:00:00Z", - "services": [ - { "serviceId": "SRV-WASH", "serviceName": "Hårvask", "baseDuration": 30, "basePrice": 100, "resourceId": "STUDENT001" }, - { "serviceId": "SRV-BABY", "serviceName": "Babylights", "baseDuration": 180, "basePrice": 1500, "resourceId": "EMP002" } - ], - "totalPrice": 1600, - "notes": "Split: Elev vasker, Isabella laver babylights" - }, - { - "id": "BOOK-NOV27-003", - "customerId": "CUST004", - "status": "created", - "createdAt": "2025-11-25T10:00:00Z", - "services": [ - { "serviceId": "SRV-KLASSISK", "serviceName": "Klassisk herreklip", "baseDuration": 30, "basePrice": 300, "resourceId": "EMP003" } - ], - "totalPrice": 300 - } -] diff --git a/wwwroot/data/mock-events.json b/wwwroot/data/mock-events.json index a34c713..b20ceab 100644 --- a/wwwroot/data/mock-events.json +++ b/wwwroot/data/mock-events.json @@ -1,352 +1,3969 @@ [ { - "id": "RES-NOV22-001", - "title": "Balayage", + "id": "1", + "title": "Team Standup", + "start": "2025-07-07T05:00:00Z", + "end": "2025-07-07T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "2", + "title": "Sprint Planning", + "start": "2025-07-07T06:00:00Z", + "end": "2025-07-07T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "3", + "title": "Development Session", + "start": "2025-07-07T10:00:00Z", + "end": "2025-07-07T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "4", + "title": "Team Standup", + "start": "2025-07-08T05:00:00Z", + "end": "2025-07-08T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "5", + "title": "Client Review", + "start": "2025-07-08T11:00:00Z", + "end": "2025-07-08T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "6", + "title": "Team Standup", + "start": "2025-07-09T05:00:00Z", + "end": "2025-07-09T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "7", + "title": "Deep Work Session", + "start": "2025-07-09T06:00:00Z", + "end": "2025-07-09T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#3f51b5" + } + }, + { + "id": "8", + "title": "Architecture Review", + "start": "2025-07-09T10:00:00Z", + "end": "2025-07-09T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "9", + "title": "Team Standup", + "start": "2025-07-10T05:00:00Z", + "end": "2025-07-10T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "10", + "title": "Lunch & Learn", + "start": "2025-07-10T08:00:00Z", + "end": "2025-07-10T09:00:00Z", + "type": "meal", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff9800" + } + }, + { + "id": "11", + "title": "Team Standup", + "start": "2025-07-11T05:00:00Z", + "end": "2025-07-11T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "12", + "title": "Sprint Review", + "start": "2025-07-11T10:00:00Z", + "end": "2025-07-11T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "13", + "title": "Weekend Project", + "start": "2025-07-12T06:00:00Z", + "end": "2025-07-12T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#f44336" + } + }, + { + "id": "14", + "title": "Team Standup", + "start": "2025-07-14T05:00:00Z", + "end": "2025-07-14T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "15", + "title": "Code Reviews", + "start": "2025-07-14T14:00:00Z", + "end": "2025-07-14T23:59:59Z", + "type": "work", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#009688" + } + }, + { + "id": "16", + "title": "Team Standup", + "start": "2025-07-15T05:00:00Z", + "end": "2025-07-15T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "17", + "title": "Product Demo", + "start": "2025-07-15T11:00:00Z", + "end": "2025-07-15T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#e91e63" + } + }, + { + "id": "18", + "title": "Team Standup", + "start": "2025-07-16T05:00:00Z", + "end": "2025-07-16T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "19", + "title": "Workshop: New Technologies", + "start": "2025-07-16T10:00:00Z", + "end": "2025-07-16T13:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#9c27b0" + } + }, + { + "id": "20", + "title": "Team Standup", + "start": "2025-07-17T05:00:00Z", + "end": "2025-07-17T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "21", + "title": "Deadline: Feature Release", + "start": "2025-07-17T13:00:00Z", + "end": "2025-07-17T13:00:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 0, + "color": "#f44336" + } + }, + { + "id": "22", + "title": "Team Standup", + "start": "2025-07-18T05:00:00Z", + "end": "2025-07-18T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "23", + "title": "Summer Team Event", + "start": "2025-07-18T00:00:00Z", + "end": "2025-07-17T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#4caf50" + } + }, + { + "id": "24", + "title": "Team Standup", + "start": "2025-07-21T05:00:00Z", + "end": "2025-07-21T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "25", + "title": "Sprint Planning", + "start": "2025-07-21T06:00:00Z", + "end": "2025-07-21T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "26", + "title": "Team Standup", + "start": "2025-07-22T05:00:00Z", + "end": "2025-07-22T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "27", + "title": "Client Meeting", + "start": "2025-07-22T10:00:00Z", + "end": "2025-07-22T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#cddc39" + } + }, + { + "id": "28", + "title": "Team Standup", + "start": "2025-07-23T05:00:00Z", + "end": "2025-07-23T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "29", + "title": "Performance Review", + "start": "2025-07-23T07:00:00Z", + "end": "2025-07-23T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "30", + "title": "Team Standup", + "start": "2025-07-24T05:00:00Z", + "end": "2025-07-24T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "31", + "title": "Technical Discussion", + "start": "2025-07-24T11:00:00Z", + "end": "2025-07-24T12:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#3f51b5" + } + }, + { + "id": "32", + "title": "Team Standup", + "start": "2025-07-25T05:00:00Z", + "end": "2025-07-25T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "33", + "title": "Sprint Review", + "start": "2025-07-25T10:00:00Z", + "end": "2025-07-25T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "34", + "title": "Team Standup", + "start": "2025-07-28T05:00:00Z", + "end": "2025-07-28T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "35", + "title": "Monthly Planning", + "start": "2025-07-28T06:00:00Z", + "end": "2025-07-28T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#9c27b0" + } + }, + { + "id": "36", + "title": "Team Standup", + "start": "2025-07-29T05:00:00Z", + "end": "2025-07-29T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "37", + "title": "Development Work", + "start": "2025-07-29T10:00:00Z", + "end": "2025-07-29T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "38", + "title": "Team Standup", + "start": "2025-07-30T05:00:00Z", + "end": "2025-07-30T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "39", + "title": "Security Review", + "start": "2025-07-30T11:00:00Z", + "end": "2025-07-30T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#f44336" + } + }, + { + "id": "40", + "title": "Team Standup", + "start": "2025-07-31T05:00:00Z", + "end": "2025-07-31T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "41", + "title": "Month End Review", + "start": "2025-07-31T10:00:00Z", + "end": "2025-07-31T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#795548" + } + }, + { + "id": "42", + "title": "Team Standup", + "start": "2025-08-01T05:00:00Z", + "end": "2025-08-01T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "43", + "title": "August Kickoff", + "start": "2025-08-01T06:00:00Z", + "end": "2025-08-01T07:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4caf50" + } + }, + { + "id": "44", + "title": "Weekend Planning", + "start": "2025-08-03T06:00:00Z", + "end": "2025-08-03T07:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#9c27b0" + } + }, + { + "id": "45", + "title": "Team Standup", + "start": "2025-08-04T05:00:00Z", + "end": "2025-08-04T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "46", + "title": "Project Kickoff", + "start": "2025-08-04T10:00:00Z", + "end": "2025-08-04T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#e91e63" + } + }, + { + "id": "47", + "title": "Company Holiday", + "start": "2025-08-04T00:00:00Z", + "end": "2025-08-04T23:59:59Z", + "type": "milestone", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#4caf50" + } + }, + { + "id": "48", + "title": "Deep Work Session", + "start": "2025-08-05T06:00:00Z", + "end": "2025-08-05T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#3f51b5" + } + }, + { + "id": "49", + "title": "Lunch Meeting", + "start": "2025-08-05T08:30:00Z", + "end": "2025-08-05T09:30:00Z", + "type": "meal", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff9800" + } + }, + { + "id": "50", + "title": "Early Morning Workout", + "start": "2025-08-05T02:00:00Z", + "end": "2025-08-05T03:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#00bcd4" + } + }, + { + "id": "51", + "title": "Client Review", + "start": "2025-08-06T11:00:00Z", + "end": "2025-08-06T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "52", + "title": "Late Evening Call", + "start": "2025-08-06T17:00:00Z", + "end": "2025-08-06T18:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#673ab7" + } + }, + { + "id": "53", + "title": "Team Building Event", + "start": "2025-08-06T00:00:00Z", + "end": "2025-08-05T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#2196f3" + } + }, + { + "id": "54", + "title": "Sprint Planning", + "start": "2025-08-07T05:00:00Z", + "end": "2025-08-07T06:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#607d8b" + } + }, + { + "id": "55", + "title": "Code Review", + "start": "2025-08-07T10:00:00Z", + "end": "2025-08-07T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#009688" + } + }, + { + "id": "56", + "title": "Midnight Deployment", + "start": "2025-08-07T19:00:00Z", + "end": "2025-08-07T21:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#ffc107" + } + }, + { + "id": "57", + "title": "Team Standup", + "start": "2025-08-08T05:00:00Z", + "end": "2025-08-08T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#8bc34a" + } + }, + { + "id": "58", + "title": "Client Meeting", + "start": "2025-08-08T10:00:00Z", + "end": "2025-08-08T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#cddc39" + } + }, + { + "id": "59", + "title": "Weekend Project", + "start": "2025-08-09T06:00:00Z", + "end": "2025-08-09T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#f44336" + } + }, + { + "id": "60", + "title": "Team Standup", + "start": "2025-08-11T05:00:00Z", + "end": "2025-08-11T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "61", + "title": "Sprint Planning", + "start": "2025-08-11T06:00:00Z", + "end": "2025-08-11T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "62", + "title": "Team Standup", + "start": "2025-08-12T05:00:00Z", + "end": "2025-08-12T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "63", + "title": "Technical Workshop", + "start": "2025-08-12T10:00:00Z", + "end": "2025-08-12T13:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#9c27b0" + } + }, + { + "id": "64", + "title": "Team Standup", + "start": "2025-08-13T05:00:00Z", + "end": "2025-08-13T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "65", + "title": "Development Session", + "start": "2025-08-13T06:00:00Z", + "end": "2025-08-13T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "66", + "title": "Team Standup", + "start": "2025-08-14T05:00:00Z", + "end": "2025-08-14T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "67", + "title": "Client Presentation", + "start": "2025-08-14T11:00:00Z", + "end": "2025-08-14T12:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#e91e63" + } + }, + { + "id": "68", + "title": "Team Standup", + "start": "2025-08-15T05:00:00Z", + "end": "2025-08-15T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "69", + "title": "Sprint Review", + "start": "2025-08-15T10:00:00Z", + "end": "2025-08-15T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "70", + "title": "Summer Festival", + "start": "2025-08-14T00:00:00Z", + "end": "2025-08-15T23:59:59Z", + "type": "milestone", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 2880, + "color": "#4caf50" + } + }, + { + "id": "71", + "title": "Team Standup", + "start": "2025-08-18T05:00:00Z", + "end": "2025-08-18T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "72", + "title": "Strategy Meeting", + "start": "2025-08-18T06:00:00Z", + "end": "2025-08-18T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#9c27b0" + } + }, + { + "id": "73", + "title": "Team Standup", + "start": "2025-08-19T05:00:00Z", + "end": "2025-08-19T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "74", + "title": "Development Work", + "start": "2025-08-19T10:00:00Z", + "end": "2025-08-19T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#3f51b5" + } + }, + { + "id": "75", + "title": "Team Standup", + "start": "2025-08-20T05:00:00Z", + "end": "2025-08-20T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "76", + "title": "Architecture Planning", + "start": "2025-08-20T11:00:00Z", + "end": "2025-08-20T12:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "77", + "title": "Team Standup", + "start": "2025-08-21T05:00:00Z", + "end": "2025-08-21T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "78", + "title": "Product Review", + "start": "2025-08-21T10:00:00Z", + "end": "2025-08-21T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "79", + "title": "Team Standup", + "start": "2025-08-22T05:00:00Z", + "end": "2025-08-22T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "80", + "title": "End of Sprint", + "start": "2025-08-22T12:00:00Z", + "end": "2025-08-22T13:00:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#f44336" + } + }, + { + "id": "81", + "title": "Team Standup", + "start": "2025-08-25T05:00:00Z", + "end": "2025-08-25T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "82", + "title": "Sprint Planning", + "start": "2025-08-25T06:00:00Z", + "end": "2025-08-25T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "83", + "title": "Team Standup", + "start": "2025-08-26T05:00:00Z", + "end": "2025-08-26T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "84", + "title": "Design Review", + "start": "2025-08-26T10:00:00Z", + "end": "2025-08-26T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#e91e63" + } + }, + { + "id": "85", + "title": "Team Standup", + "start": "2025-08-27T05:00:00Z", + "end": "2025-08-27T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "86", + "title": "Development Session", + "start": "2025-08-27T06:00:00Z", + "end": "2025-08-27T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "87", + "title": "Team Standup", + "start": "2025-08-28T05:00:00Z", + "end": "2025-08-28T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "88", + "title": "Customer Call", + "start": "2025-08-28T11:00:00Z", + "end": "2025-08-28T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#cddc39" + } + }, + { + "id": "89", + "title": "Team Standup", + "start": "2025-08-29T05:00:00Z", + "end": "2025-08-29T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "90", + "title": "Monthly Review", + "start": "2025-08-29T10:00:00Z", + "end": "2025-08-29T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#795548" + } + }, + { + "id": "91", + "title": "Team Standup", + "start": "2025-09-01T05:00:00Z", + "end": "2025-09-01T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "92", + "title": "September Kickoff", + "start": "2025-09-01T06:00:00Z", + "end": "2025-09-01T07:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4caf50" + } + }, + { + "id": "93", + "title": "Team Standup", + "start": "2025-09-02T05:00:00Z", + "end": "2025-09-02T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "94", + "title": "Product Planning", + "start": "2025-09-02T10:00:00Z", + "end": "2025-09-02T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#9c27b0" + } + }, + { + "id": "95", + "title": "Team Standup", + "start": "2025-09-03T05:00:00Z", + "end": "2025-09-03T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "96", + "title": "Deep Work", + "start": "2025-09-02T11:00:00Z", + "end": "2025-09-02T11:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#3f51b5" + } + }, + { + "id": "97", + "title": "Team Standup", + "start": "2025-09-04T05:00:00Z", + "end": "2025-09-04T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "98", + "title": "Technical Review", + "start": "2025-09-04T11:00:00Z", + "end": "2025-09-04T12:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "99", + "title": "Team Standup", + "start": "2025-09-05T05:00:00Z", + "end": "2025-09-05T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "100", + "title": "Sprint Review", + "start": "2025-09-04T11:00:00Z", + "end": "2025-09-04T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "101", + "title": "Weekend Workshop", + "start": "2025-09-06T06:00:00Z", + "end": "2025-09-06T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#f44336" + } + }, + { + "id": "102", + "title": "Team Standup", + "start": "2025-09-08T05:00:00Z", + "end": "2025-09-08T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "103", + "title": "Sprint Planning", + "start": "2025-09-08T06:00:00Z", + "end": "2025-09-08T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "104", + "title": "Team Standup", + "start": "2025-09-09T05:00:00Z", + "end": "2025-09-09T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "105", + "title": "Client Workshop", + "start": "2025-09-09T10:00:00Z", + "end": "2025-09-09T13:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#e91e63" + } + }, + { + "id": "106", + "title": "Team Standup", + "start": "2025-09-10T05:00:00Z", + "end": "2025-09-10T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "107", + "title": "Development Work", + "start": "2025-09-10T06:00:00Z", + "end": "2025-09-10T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "108", + "title": "Team Standup", + "start": "2025-09-11T05:00:00Z", + "end": "2025-09-11T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "109", + "title": "Performance Review", + "start": "2025-09-11T11:00:00Z", + "end": "2025-09-11T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "110", + "title": "Team Standup", + "start": "2025-09-12T05:00:00Z", + "end": "2025-09-12T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "111", + "title": "Q3 Review", + "start": "2025-09-12T10:00:00Z", + "end": "2025-09-12T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#9c27b0" + } + }, + { + "id": "112", + "title": "Autumn Equinox", + "start": "2025-09-23T00:00:00Z", + "end": "2025-09-22T23:59:59Z", + "type": "milestone", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#ff6f00" + } + }, + { + "id": "113", + "title": "Team Standup", + "start": "2025-09-15T05:00:00Z", + "end": "2025-09-15T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "114", + "title": "Weekly Planning", + "start": "2025-09-15T06:00:00Z", + "end": "2025-09-15T07:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#3f51b5" + } + }, + { + "id": "115", + "title": "Team Standup", + "start": "2025-09-16T05:00:00Z", + "end": "2025-09-16T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "116", + "title": "Feature Demo", + "start": "2025-09-16T11:00:00Z", + "end": "2025-09-16T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#cddc39" + } + }, + { + "id": "117", + "title": "Team Standup", + "start": "2025-09-17T05:00:00Z", + "end": "2025-09-17T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "118", + "title": "Code Refactoring", + "start": "2025-09-17T06:00:00Z", + "end": "2025-09-17T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#009688" + } + }, + { + "id": "119", + "title": "Team Standup", + "start": "2025-09-18T05:00:00Z", + "end": "2025-09-18T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "120", + "title": "End of Sprint", + "start": "2025-09-19T12:00:00Z", + "end": "2025-09-19T13:00:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#f44336" + } + }, + { + "id": "121", + "title": "Azure Setup", + "start": "2025-09-10T06:30:00Z", + "end": "2025-09-10T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "122", + "title": "Multi-Day Conference", + "start": "2025-09-22T00:00:00Z", + "end": "2025-09-23T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 4320, + "color": "#4caf50" + } + }, + { + "id": "123", + "title": "Project Sprint", + "start": "2025-09-23T00:00:00Z", + "end": "2025-09-24T23:59:59Z", + "type": "work", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 4320, + "color": "#2196f3" + } + }, + { + "id": "124", + "title": "Training Week", + "start": "2025-09-29T00:00:00Z", + "end": "2025-10-02T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 7200, + "color": "#9c27b0" + } + }, + { + "id": "125", + "title": "Holiday Weekend", + "start": "2025-10-04T00:00:00Z", + "end": "2025-10-05T23:59:59Z", + "type": "milestone", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 4320, + "color": "#ff6f00" + } + }, + { + "id": "126", + "title": "Client Visit", + "start": "2025-10-07T00:00:00Z", + "end": "2025-10-08T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 4320, + "color": "#e91e63" + } + }, + { + "id": "127", + "title": "Development Marathon", + "start": "2025-10-13T00:00:00Z", + "end": "2025-10-14T23:59:59Z", + "type": "work", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 4320, + "color": "#3f51b5" + } + }, + { + "id": "128", + "title": "Morgen Standup", + "start": "2025-09-22T05:00:00Z", + "end": "2025-09-22T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "129", + "title": "Klient Præsentation", + "start": "2025-09-22T10:00:00Z", + "end": "2025-09-22T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#e91e63" + } + }, + { + "id": "130", + "title": "Eftermiddags Kodning", + "start": "2025-09-22T12:00:00Z", + "end": "2025-09-22T14:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "131", + "title": "Team Standup", + "start": "2025-09-23T05:00:00Z", + "end": "2025-09-23T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "132", + "title": "Arkitektur Review", + "start": "2025-09-23T07:00:00Z", + "end": "2025-09-23T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "133", + "title": "Frokost & Læring", + "start": "2025-09-23T08:30:00Z", + "end": "2025-09-23T09:30:00Z", + "type": "meal", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff9800" + } + }, + { + "id": "134", + "title": "Team Standup", + "start": "2025-09-24T05:00:00Z", + "end": "2025-09-24T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "135", + "title": "Database Optimering", + "start": "2025-09-24T06:00:00Z", + "end": "2025-09-24T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#3f51b5" + } + }, + { + "id": "136", + "title": "Klient Opkald", + "start": "2025-09-24T11:00:00Z", + "end": "2025-09-24T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "137", + "title": "Team Standup", + "start": "2025-09-25T05:00:00Z", + "end": "2025-09-25T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "138", + "title": "Sprint Review", + "start": "2025-09-25T10:00:00Z", + "end": "2025-09-25T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "139", + "title": "Retrospektiv", + "start": "2025-09-25T11:30:00Z", + "end": "2025-09-25T12:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#9c27b0" + } + }, + { + "id": "140", + "title": "Team Standup", + "start": "2025-09-26T05:00:00Z", + "end": "2025-09-26T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "141", + "title": "Ny Feature Udvikling", + "start": "2025-09-26T06:00:00Z", + "end": "2025-09-26T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#4caf50" + } + }, + { + "id": "142", + "title": "Sikkerhedsgennemgang", + "start": "2025-09-26T10:00:00Z", + "end": "2025-09-26T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#f44336" + } + }, + { + "id": "143", + "title": "Weekend Hackathon", + "start": "2025-09-27T00:00:00Z", + "end": "2025-09-27T23:59:59Z", + "type": "work", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 2880, + "color": "#673ab7" + } + }, + { + "id": "144", + "title": "Team Standup", + "start": "2025-09-29T07:30:00Z", + "end": "2025-09-29T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "145", + "title": "Månedlig Planlægning", + "start": "2025-09-29T07:00:00Z", + "end": "2025-09-29T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#9c27b0" + } + }, + { + "id": "146", + "title": "Performance Test", + "start": "2025-09-29T08:15:00Z", + "end": "2025-09-29T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#00bcd4" + } + }, + { + "id": "147", + "title": "Team Standup", + "start": "2025-09-30T05:00:00Z", + "end": "2025-09-30T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "148", + "title": "Kvartal Afslutning", + "start": "2025-09-30T11:00:00Z", + "end": "2025-09-30T13:00:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#f44336" + } + },{ + "id": "1481", + "title": "Kvartal Afslutning 2", + "start": "2025-09-30T11:20:00Z", + "end": "2025-09-30T13:00:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#f44336" + } + }, + { + "id": "149", + "title": "Oktober Kickoff", + "start": "2025-10-01T05:00:00Z", + "end": "2025-10-01T06:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4caf50" + } + }, + { + "id": "150", + "title": "Sprint Planlægning", + "start": "2025-10-01T06:30:00Z", + "end": "2025-10-01T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "151", + "title": "Eftermiddags Kodning", + "start": "2025-10-01T10:00:00Z", + "end": "2025-10-01T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "1511", + "title": "Eftermiddags Kodning", + "start": "2025-10-01T10:30:00Z", + "end": "2025-10-01T11:00:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "1512", + "title": "Eftermiddags Kodning", + "start": "2025-10-01T11:30:00Z", + "end": "2025-10-01T12:30:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "1513", + "title": "Eftermiddags Kodning", + "start": "2025-10-01T12:00:00Z", + "end": "2025-10-01T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "1514", + "title": "Eftermiddags Kodning 2", + "start": "2025-10-01T12:00:00Z", + "end": "2025-10-01T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "152", + "title": "Team Standup", + "start": "2025-10-02T05:00:00Z", + "end": "2025-10-02T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "153", + "title": "API Design Workshop", + "start": "2025-10-02T07:00:00Z", + "end": "2025-10-02T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "154", + "title": "Bug Fixing Session", + "start": "2025-10-02T07:00:00Z", + "end": "2025-10-02T09:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#ff5722" + } + }, + { + "id": "155", + "title": "Team Standup", + "start": "2025-10-03T05:00:00Z", + "end": "2025-10-03T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "156", + "title": "Klient Demo", + "start": "2025-10-03T10:00:00Z", + "end": "2025-10-03T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#e91e63" + } + }, + { + "id": "157", + "title": "Code Review Session", + "start": "2025-10-03T12:00:00Z", + "end": "2025-10-03T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#009688" + } + }, + { + "id": "158", + "title": "Fredag Standup", + "start": "2025-10-04T05:00:00Z", + "end": "2025-10-04T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "159", + "title": "Uge Retrospektiv", + "start": "2025-10-04T11:00:00Z", + "end": "2025-10-04T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#9c27b0" + } + }, + { + "id": "160", + "title": "Weekend Projekt", + "start": "2025-10-05T06:00:00Z", + "end": "2025-10-05T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 240, + "color": "#3f51b5" + } + }, + { + "id": "161", + "title": "Teknisk Workshop", + "start": "2025-09-24T00:00:00Z", + "end": "2025-09-25T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 4320, + "color": "#795548" + } + }, + { + "id": "162", + "title": "Produktudvikling Sprint", + "start": "2025-10-01T08:00:00Z", + "end": "2025-10-02T21:00:00Z", + "type": "work", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 4320, + "color": "#cddc39" + } + }, + { + "id": "163", + "title": "Tidlig Morgen Træning", + "start": "2025-09-23T02:30:00Z", + "end": "2025-09-23T03:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#00bcd4" + } + }, + { + "id": "164", + "title": "Sen Aften Deploy", + "start": "2025-09-25T18:00:00Z", + "end": "2025-09-25T20:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 150, + "color": "#ffc107" + } + }, + { + "id": "165", + "title": "Overlappende Møde A", + "start": "2025-09-30T06:00:00Z", + "end": "2025-09-30T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#8bc34a" + } + }, + { + "id": "166", + "title": "Overlappende Møde B", + "start": "2025-09-30T06:30:00Z", + "end": "2025-09-30T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#ff6f00" + } + }, + { + "id": "167", + "title": "Kort Check-in", + "start": "2025-10-02T05:45:00Z", + "end": "2025-10-02T06:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 15, + "color": "#607d8b" + } + }, + { + "id": "168", + "title": "Lang Udviklingssession", + "start": "2025-10-04T05:00:00Z", + "end": "2025-10-04T09:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 240, + "color": "#2196f3" + } + }, + { + "id": "S1A", + "title": "Scenario 1: Event A", + "start": "2025-10-06T05:00:00Z", + "end": "2025-10-06T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 300, + "color": "#ff6b6b" + } + }, + { + "id": "S1B", + "title": "Scenario 1: Event B", + "start": "2025-10-06T06:00:00Z", + "end": "2025-10-06T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#4ecdc4" + } + }, + { + "id": "S1C", + "title": "Scenario 1: Event C", + "start": "2025-10-06T08:30:00Z", + "end": "2025-10-06T09:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ffe66d" + } + }, + { + "id": "S2A", + "title": "Scenario 2: Event A", + "start": "2025-10-06T11:00:00Z", + "end": "2025-10-06T17:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 360, + "color": "#ff6b6b" + } + }, + { + "id": "S2B", + "title": "Scenario 2: Event B", + "start": "2025-10-06T12:00:00Z", + "end": "2025-10-06T13:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4ecdc4" + } + }, + { + "id": "S2C", + "title": "Scenario 2: Event C", + "start": "2025-10-06T13:30:00Z", + "end": "2025-10-06T14:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S2D", + "title": "Scenario 2: Event D", + "start": "2025-10-06T15:00:00Z", + "end": "2025-10-06T16:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#a8e6cf" + } + }, + { + "id": "S3A", + "title": "Scenario 3: Event A", + "start": "2025-10-07T07:00:00Z", + "end": "2025-10-07T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 360, + "color": "#ff6b6b" + } + }, + { + "id": "S3B", + "title": "Scenario 3: Event B", + "start": "2025-10-07T08:00:00Z", + "end": "2025-10-07T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#4ecdc4" + } + }, + { + "id": "S3C", + "title": "Scenario 3: Event C", + "start": "2025-10-07T09:00:00Z", + "end": "2025-10-07T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S3D", + "title": "Scenario 3: Event D", + "start": "2025-10-07T10:30:00Z", + "end": "2025-10-07T11:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#a8e6cf" + } + }, + { + "id": "S4A", + "title": "Scenario 4: Event A", + "start": "2025-10-07T14:00:00Z", + "end": "2025-10-07T20:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 360, + "color": "#ff6b6b" + } + }, + { + "id": "S4B", + "title": "Scenario 4: Event B", + "start": "2025-10-07T15:00:00Z", + "end": "2025-10-07T19:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 240, + "color": "#4ecdc4" + } + }, + { + "id": "S4C", + "title": "Scenario 4: Event C", + "start": "2025-10-07T16:00:00Z", + "end": "2025-10-07T18:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#ffe66d" + } + }, + { + "id": "S5A", + "title": "Scenario 5: Event A", + "start": "2025-10-08T05:00:00Z", + "end": "2025-10-08T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#ff6b6b" + } + }, + { + "id": "S5B", + "title": "Scenario 5: Event B", + "start": "2025-10-08T06:00:00Z", + "end": "2025-10-08T07:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#4ecdc4" + } + }, + { + "id": "S5C", + "title": "Scenario 5: Event C", + "start": "2025-10-08T06:00:00Z", + "end": "2025-10-08T07:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S6A", + "title": "Scenario 6: Event A", + "start": "2025-10-08T09:00:00Z", + "end": "2025-10-08T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#ff6b6b" + } + }, + { + "id": "S6B", + "title": "Scenario 6: Event B", + "start": "2025-10-08T10:00:00Z", + "end": "2025-10-08T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#4ecdc4" + } + }, + { + "id": "S6C", + "title": "Scenario 6: Event C", + "start": "2025-10-08T10:00:00Z", + "end": "2025-10-08T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S6D", + "title": "Scenario 6: Event D", + "start": "2025-10-08T10:30:00Z", + "end": "2025-10-08T10:45:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 15, + "color": "#a8e6cf" + } + }, + { + "id": "S7A", + "title": "Scenario 7: Event A", + "start": "2025-10-09T05:00:00Z", + "end": "2025-10-09T07:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 150, + "color": "#009688" + } + }, + { + "id": "S7B", + "title": "Scenario 7: Event B", + "start": "2025-10-09T05:00:00Z", + "end": "2025-10-09T07:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#ff5722" + } + }, + { + "id": "S8A", + "title": "Scenario 8: Event A", + "start": "2025-10-09T08:00:00Z", + "end": "2025-10-09T09:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff6b6b" + } + }, + { + "id": "S8B", + "title": "Scenario 8: Event B", + "start": "2025-10-09T08:15:00Z", + "end": "2025-10-09T09:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 75, + "color": "#4ecdc4" + } + }, + { + "id": "S9A", + "title": "Scenario 9: Event A", + "start": "2025-10-09T10:00:00Z", + "end": "2025-10-09T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff6b6b" + } + }, + { + "id": "S9B", + "title": "Scenario 9: Event B", + "start": "2025-10-09T10:30:00Z", + "end": "2025-10-09T11:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4ecdc4" + } + }, + { + "id": "S9C", + "title": "Scenario 9: Event C", + "start": "2025-10-09T11:15:00Z", + "end": "2025-10-09T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 105, + "color": "#ffe66d" + } + }, + { + "id": "S10A", + "title": "Scenario 10: Event A", + "start": "2025-10-10T10:00:00Z", + "end": "2025-10-10T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#ff6b6b" + } + }, + { + "id": "S10B", + "title": "Scenario 10: Event B", + "start": "2025-10-10T10:30:00Z", + "end": "2025-10-10T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#4ecdc4" + } + }, + { + "id": "S10C", + "title": "Scenario 10: Event C", + "start": "2025-10-10T11:30:00Z", + "end": "2025-10-10T12:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S10D", + "title": "Scenario 10: Event D", + "start": "2025-10-10T12:00:00Z", + "end": "2025-10-10T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#a8e6cf" + } + }, + { + "id": "S10E", + "title": "Scenario 10: Event E", + "start": "2025-10-10T12:00:00Z", + "end": "2025-10-10T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#dda15e" + } + }, + { + "id": "169", + "title": "Morgen Standup", + "start": "2025-10-13T05:00:00Z", + "end": "2025-10-13T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "170", + "title": "Produktvejledning", + "start": "2025-10-13T07:00:00Z", + "end": "2025-10-13T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#9c27b0" + } + }, + { + "id": "171", + "title": "Team Standup", + "start": "2025-10-14T05:00:00Z", + "end": "2025-10-14T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "172", + "title": "Udviklingssession", + "start": "2025-10-14T06:00:00Z", + "end": "2025-10-14T09:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "173", + "title": "Klient Gennemgang", + "start": "2025-10-15T11:00:00Z", + "end": "2025-10-15T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "174", + "title": "Team Standup", + "start": "2025-10-16T05:00:00Z", + "end": "2025-10-16T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "175", + "title": "Arkitektur Workshop", + "start": "2025-10-16T10:00:00Z", + "end": "2025-10-16T13:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#009688" + } + }, + { + "id": "176", + "title": "Team Standup", + "start": "2025-10-17T05:00:00Z", + "end": "2025-10-17T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "177", + "title": "Sprint Review", + "start": "2025-10-17T10:00:00Z", + "end": "2025-10-17T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "178", + "title": "Weekend Kodning", + "start": "2025-10-18T06:00:00Z", + "end": "2025-10-18T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 240, + "color": "#3f51b5" + } + }, + { + "id": "179", + "title": "Team Standup", + "start": "2025-10-27T05:00:00Z", + "end": "2025-10-27T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "180", + "title": "Sprint Planning", + "start": "2025-10-27T06:00:00Z", + "end": "2025-10-27T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "181", + "title": "Development Session", + "start": "2025-10-27T10:00:00Z", + "end": "2025-10-27T12:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "182", + "title": "Team Standup", + "start": "2025-10-28T05:00:00Z", + "end": "2025-10-28T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "183", + "title": "Client Review", + "start": "2025-10-28T11:00:00Z", + "end": "2025-10-28T12:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#795548" + } + }, + { + "id": "184", + "title": "Database Optimization", + "start": "2025-10-28T13:00:00Z", + "end": "2025-10-28T15:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#3f51b5" + } + }, + { + "id": "185", + "title": "Team Standup", + "start": "2025-10-29T05:00:00Z", + "end": "2025-10-29T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "186", + "title": "Architecture Review", + "start": "2025-10-29T08:00:00Z", + "end": "2025-10-29T09:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "187", + "title": "Lunch & Learn", + "start": "2025-10-29T11:00:00Z", + "end": "2025-10-29T12:00:00Z", + "type": "meal", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff9800" + } + }, + { + "id": "188", + "title": "Team Standup", + "start": "2025-10-30T05:00:00Z", + "end": "2025-10-30T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "189", + "title": "Product Demo", + "start": "2025-10-30T10:00:00Z", + "end": "2025-10-30T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#e91e63" + } + }, + { + "id": "190", + "title": "Code Review Session", + "start": "2025-10-30T13:00:00Z", + "end": "2025-10-30T14:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "191", + "title": "Team Standup", + "start": "2025-10-31T05:00:00Z", + "end": "2025-10-31T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "192", + "title": "Halloween Party Planning", + "start": "2025-10-31T10:00:00Z", + "end": "2025-10-31T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff6f00" + } + }, + { + "id": "193", + "title": "Sprint Review", + "start": "2025-10-31T14:00:00Z", + "end": "2025-10-31T15:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "194", + "title": "Company Training Week", + "start": "2025-10-27T00:00:00Z", + "end": "2025-10-30T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 5760, + "color": "#9c27b0" + } + }, + { + "id": "195", + "title": "Halloween Celebration", + "start": "2025-10-31T00:00:00Z", + "end": "2025-10-31T23:59:59Z", + "type": "milestone", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#ff6f00" + } + }, + { + "id": "196", + "title": "Team Standup", + "start": "2025-11-03T05:00:00Z", + "end": "2025-11-03T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "197", + "title": "Sprint Planning", + "start": "2025-11-03T06:00:00Z", + "end": "2025-11-03T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "198", + "title": "Deep Work Session", + "start": "2025-11-03T10:00:00Z", + "end": "2025-11-03T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#3f51b5" + } + }, + { + "id": "199", + "title": "Team Standup", + "start": "2025-11-04T05:00:00Z", + "end": "2025-11-04T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "200", + "title": "Client Workshop", + "start": "2025-11-04T11:00:00Z", + "end": "2025-11-04T13:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#e91e63" + } + }, + { + "id": "201", + "title": "Feature Development", + "start": "2025-11-04T14:00:00Z", + "end": "2025-11-04T16:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "202", + "title": "Team Standup", + "start": "2025-11-05T05:00:00Z", + "end": "2025-11-05T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "203", + "title": "Technical Discussion", + "start": "2025-11-05T08:00:00Z", + "end": "2025-11-05T09:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "204", + "title": "Performance Testing", + "start": "2025-11-05T11:00:00Z", + "end": "2025-11-05T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#00bcd4" + } + }, + { + "id": "205", + "title": "Team Standup", + "start": "2025-11-06T05:00:00Z", + "end": "2025-11-06T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "206", + "title": "Security Review", + "start": "2025-11-06T10:00:00Z", + "end": "2025-11-06T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#f44336" + } + }, + { + "id": "207", + "title": "API Development", + "start": "2025-11-06T13:00:00Z", + "end": "2025-11-06T15:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#2196f3" + } + }, + { + "id": "208", + "title": "Team Standup", + "start": "2025-11-07T05:00:00Z", + "end": "2025-11-07T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "209", + "title": "Weekly Retrospective", + "start": "2025-11-07T10:00:00Z", + "end": "2025-11-07T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#9c27b0" + } + }, + { + "id": "210", + "title": "Sprint Review", + "start": "2025-11-07T14:00:00Z", + "end": "2025-11-07T15:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "211", + "title": "November Team Building", + "start": "2025-11-03T00:00:00Z", + "end": "2025-11-04T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 2880, + "color": "#4caf50" + } + }, + { + "id": "212", + "title": "Q4 Strategy Planning", + "start": "2025-11-06T00:00:00Z", + "end": "2025-11-07T23:59:59Z", + "type": "milestone", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 2880, + "color": "#9c27b0" + } + }, + { + "id": "NOV10-001", + "title": "Morgen Standup", + "description": "Daily team sync - status updates", + "start": "2025-11-10T05:00:00Z", + "end": "2025-11-10T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "NOV10-002", + "title": "Sprint Planning", + "description": "Plan backlog items and estimate story points", + "start": "2025-11-10T06:00:00Z", + "end": "2025-11-10T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "NOV10-003", + "title": "Udvikling af ny feature", + "description": "Implement user authentication module with OAuth2 support, JWT tokens, refresh token rotation, and secure password hashing using bcrypt. Include comprehensive unit tests and integration tests for all authentication flows.", + "start": "2025-11-10T08:00:00Z", + "end": "2025-11-10T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "NOV10-004", + "title": "Frokostmøde med klient", + "description": "Discuss project requirements and timeline", + "start": "2025-11-10T08:00:00Z", + "end": "2025-11-10T09:00:00Z", + "type": "meal", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff9800" + } + }, + { + "id": "NOV10-ALL", + "title": "Konference Dag 1", + "start": "2025-11-10T00:00:00Z", + "end": "2025-11-10T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#4caf50" + } + }, + { + "id": "NOV11-001", + "title": "Morgen Standup", + "description": "Quick sync on progress and blockers", + "start": "2025-11-11T05:00:00Z", + "end": "2025-11-11T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "NOV11-002", + "title": "Arkitektur Review", + "description": "Review system design and scalability", + "start": "2025-11-11T07:00:00Z", + "end": "2025-11-11T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "NOV11-003", + "title": "Code Review Session", + "description": "Review pull requests and provide feedback", + "start": "2025-11-11T10:00:00Z", + "end": "2025-11-11T11:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "NOV11-004", + "title": "Database Optimering", + "description": "Optimize queries and add indexes", + "start": "2025-11-11T13:00:00Z", + "end": "2025-11-11T15:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#3f51b5" + } + }, + { + "id": "NOV11-ALL", + "title": "Konference Dag 2", + "start": "2025-11-11T00:00:00Z", + "end": "2025-11-11T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#4caf50" + } + }, + { + "id": "NOV12-001", + "title": "Morgen Standup", + "description": "Team alignment and daily planning", + "start": "2025-11-12T05:00:00Z", + "end": "2025-11-12T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "NOV12-002", + "title": "Teknisk Workshop", + "description": "Learn new frameworks and best practices", + "start": "2025-11-12T06:00:00Z", + "end": "2025-11-12T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#9c27b0" + } + }, + { + "id": "NOV12-003", + "title": "API Udvikling", + "description": "Build REST endpoints for mobile app including user profile management, push notifications, real-time chat functionality, file upload with image compression, and comprehensive API documentation using OpenAPI specification. Implement rate limiting and caching strategies.", + "start": "2025-11-12T09:00:00Z", + "end": "2025-11-12T12:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "NOV12-004", + "title": "Klient Præsentation", + "description": "Demo new features and gather feedback", + "start": "2025-11-12T13:00:00Z", + "end": "2025-11-12T14:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#e91e63" + } + }, + { + "id": "NOV13-001", + "title": "Morgen Standup", + "description": "Daily sync and impediment removal", + "start": "2025-11-13T05:00:00Z", + "end": "2025-11-13T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "NOV13-002", + "title": "Performance Testing", + "description": "Load testing and bottleneck analysis", + "start": "2025-11-13T07:00:00Z", + "end": "2025-11-13T09:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#00bcd4" + } + }, + { + "id": "NOV13-003", + "title": "Sikkerhedsgennemgang", + "description": "Security audit and vulnerability scan", + "start": "2025-11-13T10:00:00Z", + "end": "2025-11-13T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#f44336" + } + }, + { + "id": "NOV13-004", + "title": "Bug Fixing Session", + "description": "Fix critical bugs from production", + "start": "2025-11-13T13:00:00Z", + "end": "2025-11-13T15:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#ff5722" + } + }, + { + "id": "NOV13-ALL", + "title": "Team Building Event", + "start": "2025-11-13T00:00:00Z", + "end": "2025-11-13T23:59:59Z", + "type": "meeting", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#2196f3" + } + }, + { + "id": "NOV14-001", + "title": "Morgen Standup", + "description": "Sprint wrap-up and final status check", + "start": "2025-11-14T05:00:00Z", + "end": "2025-11-14T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "NOV14-002", + "title": "Sprint Review", + "description": "Demo completed work to stakeholders", + "start": "2025-11-14T06:00:00Z", + "end": "2025-11-14T07:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#607d8b" + } + }, + { + "id": "NOV14-003", + "title": "Retrospektiv", + "description": "Reflect on sprint and identify improvements", + "start": "2025-11-14T07:30:00Z", + "end": "2025-11-14T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#9c27b0" + } + }, + { + "id": "NOV14-004", + "title": "Dokumentation", + "description": "Update technical documentation including architecture diagrams, API reference with request/response examples, deployment guides for production and staging environments, troubleshooting section with common issues and solutions, and developer onboarding documentation with setup instructions.", + "start": "2025-11-14T10:00:00Z", + "end": "2025-11-14T12:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#795548" + } + }, + { + "id": "NOV14-005", + "title": "Deployment Planning", + "description": "Plan release strategy and rollback", + "start": "2025-11-14T13:00:00Z", + "end": "2025-11-14T14:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffc107" + } + }, + { + "id": "NOV15-001", + "title": "Morgen Standup", + "description": "New sprint kickoff and goal setting", + "start": "2025-11-15T05:00:00Z", + "end": "2025-11-15T05:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "NOV15-002", + "title": "Feature Demo", + "description": "Showcase new functionality to team", + "start": "2025-11-15T07:00:00Z", + "end": "2025-11-15T08:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#cddc39" + } + }, + { + "id": "NOV15-003", + "title": "Refactoring Session", + "description": "Clean up technical debt and improve code", + "start": "2025-11-15T09:00:00Z", + "end": "2025-11-15T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#009688" + } + }, + { + "id": "NOV15-004", + "title": "Klient Opkald", + "description": "Weekly status update and next steps", + "start": "2025-11-15T13:00:00Z", + "end": "2025-11-15T14:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#795548" + } + }, + { + "id": "NOV15-ALL", + "title": "Virksomhedsdag", + "start": "2025-11-15T00:00:00Z", + "end": "2025-11-15T23:59:59Z", + "type": "milestone", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#ff6f00" + } + }, + { + "id": "NOV16-001", + "title": "Weekend Projekt", + "description": "Personal coding project and experimentation", + "start": "2025-11-16T06:00:00Z", + "end": "2025-11-16T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 240, + "color": "#3f51b5" + } + }, + { + "id": "NOV16-002", + "title": "Personlig Udvikling", + "description": "Learn new technologies and skills", + "start": "2025-11-16T11:00:00Z", + "end": "2025-11-16T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#8bc34a" + } + }, + { + "id": "NOV10-16-MULTI", + "title": "Uge 46 - Projekt Sprint", + "start": "2025-11-10T00:00:00Z", + "end": "2025-11-16T23:59:59Z", + "type": "work", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 10080, + "color": "#673ab7" + } + }, + { + "id": "NOV17-001", + "title": "Morning Workout", + "description": "Sunday morning fitness routine", + "start": "2025-11-17T07:00:00Z", + "end": "2025-11-17T08:30:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#4caf50" + } + }, + { + "id": "NOV17-002", + "title": "Familietid", + "description": "Quality time with family", + "start": "2025-11-17T11:00:00Z", + "end": "2025-11-17T14:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#ff9800" + } + }, + { + "id": "NOV18-001", + "title": "Monday Morning Standup", + "description": "Weekly team sync meeting", + "start": "2025-11-18T07:00:00Z", + "end": "2025-11-18T07:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#2196f3" + } + }, + { + "id": "NOV18-002", + "title": "Development Work", + "description": "Feature implementation session", + "start": "2025-11-18T08:00:00Z", + "end": "2025-11-18T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#3f51b5" + } + }, + { + "id": "NOV18-003", + "title": "Lunch Møde", + "description": "Business lunch with client", + "start": "2025-11-18T11:00:00Z", + "end": "2025-11-18T12:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#e91e63" + } + }, + { + "id": "NOV19-001", + "title": "Code Review Session", + "description": "Review pull requests and merge", + "start": "2025-11-19T07:00:00Z", + "end": "2025-11-19T08:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#009688" + } + }, + { + "id": "NOV19-002", + "title": "Team Sync", + "description": "Cross-team coordination meeting", + "start": "2025-11-19T09:00:00Z", + "end": "2025-11-19T10:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#673ab7" + } + }, + { + "id": "NOV19-003", + "title": "Kunde Møde", + "description": "Project status update with client", + "start": "2025-11-19T13:00:00Z", + "end": "2025-11-19T14:30:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#ff5722" + } + }, + { + "id": "NOV20-001", + "title": "Sprint Planning", + "description": "Plan next sprint tasks and goals", + "start": "2025-11-20T07:00:00Z", + "end": "2025-11-20T09:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#795548" + } + }, + { + "id": "NOV20-002", + "title": "Development Work", + "description": "Implement new features", + "start": "2025-11-20T09:30:00Z", + "end": "2025-11-20T12:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#3f51b5" + } + }, + { + "id": "NOV21-001", + "title": "Client Presentation", + "description": "Demo and feature walkthrough", + "start": "2025-11-21T08:00:00Z", + "end": "2025-11-21T10:00:00Z", + "type": "customer", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#e91e63" + } + }, + { + "id": "NOV21-002", + "title": "Technical Discussion", + "description": "Architecture review and planning", + "start": "2025-11-21T10:30:00Z", + "end": "2025-11-21T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#607d8b" + } + }, + { + "id": "NOV21-003", + "title": "Testing & QA", + "description": "Test new features and bug fixes", + "start": "2025-11-21T13:00:00Z", + "end": "2025-11-21T16:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#8bc34a" + } + }, + { + "id": "NOV22-001", + "title": "Team Retrospective", + "description": "Sprint review and improvements", + "start": "2025-11-22T07:00:00Z", + "end": "2025-11-22T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#ff9800" + } + }, + { + "id": "NOV22-002", + "title": "Documentation", + "description": "Update project documentation", "start": "2025-11-22T09:00:00Z", "end": "2025-11-22T11:00:00Z", - "type": "customer", + "type": "work", "allDay": false, - "bookingId": "BOOK-NOV22-001", - "resourceId": "EMP001", - "customerId": "CUST001", "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } + "metadata": { + "duration": 120, + "color": "#00bcd4" + } }, { - "id": "RES-NOV22-002", - "title": "Herreklipning", - "start": "2025-11-22T09:30:00Z", - "end": "2025-11-22T10:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", + "id": "NOV17-ALLDAY", + "title": "Weekend Aktivitet", + "start": "2025-11-17T00:00:00Z", + "end": "2025-11-17T23:59:59Z", + "type": "break", + "allDay": true, "syncStatus": "synced", - "metadata": { "duration": 30, "color": "indigo" } + "metadata": { + "duration": 1440, + "color": "#4caf50" + } }, { - "id": "RES-NOV22-003", - "title": "Farvning", - "start": "2025-11-22T10:00:00Z", - "end": "2025-11-22T12:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", + "id": "NOV18-20-MULTI", + "title": "Projekt Sprint - Uge 47", + "start": "2025-11-18T00:00:00Z", + "end": "2025-11-20T23:59:59Z", + "type": "work", + "allDay": true, "syncStatus": "synced", - "metadata": { "duration": 120, "color": "pink" } + "metadata": { + "duration": 4320, + "color": "#673ab7" + } }, { - "id": "RES-NOV22-004", - "title": "Styling", - "start": "2025-11-22T13:00:00Z", - "end": "2025-11-22T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP001", + "id": "NOV20-ALLDAY", + "title": "Tech Conference", + "start": "2025-11-20T00:00:00Z", + "end": "2025-11-20T23:59:59Z", + "type": "meeting", + "allDay": true, "syncStatus": "synced", - "metadata": { "duration": 60, "color": "purple" } + "metadata": { + "duration": 1440, + "color": "#ff6f00" + } }, { - "id": "RES-NOV22-005", - "title": "Vask og føn", - "start": "2025-11-22T11:00:00Z", - "end": "2025-11-22T11:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", + "id": "NOV21-22-MULTI", + "title": "Training Session", + "start": "2025-11-21T00:00:00Z", + "end": "2025-11-22T23:59:59Z", + "type": "meeting", + "allDay": true, "syncStatus": "synced", - "metadata": { "duration": 30, "color": "light-green" } + "metadata": { + "duration": 2880, + "color": "#9c27b0" + } }, { - "id": "RES-NOV22-006", - "title": "Klipning dame", - "start": "2025-11-22T14:00:00Z", - "end": "2025-11-22T15:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", + "id": "NOV23-ALLDAY", + "title": "Personlig Dag", + "start": "2025-11-23T00:00:00Z", + "end": "2025-11-23T23:59:59Z", + "type": "break", + "allDay": true, "syncStatus": "synced", - "metadata": { "duration": 60, "color": "teal" } - }, - { - "id": "RES-NOV23-001", - "title": "Permanent", - "start": "2025-11-23T09:00:00Z", - "end": "2025-11-23T11:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 150, "color": "pink" } - }, - { - "id": "RES-NOV23-002", - "title": "Skæg trimning", - "start": "2025-11-23T10:00:00Z", - "end": "2025-11-23T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "indigo" } - }, - { - "id": "RES-NOV23-003", - "title": "Highlights", - "start": "2025-11-23T12:00:00Z", - "end": "2025-11-23T14:00:00Z", - "type": "customer", - "allDay": false, - "bookingId": "BOOK-NOV22-001", - "resourceId": "EMP001", - "customerId": "CUST001", - "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } - }, - { - "id": "RES-NOV23-004", - "title": "Assistance", - "start": "2025-11-23T13:00:00Z", - "end": "2025-11-23T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT002", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "orange" } - }, - { - "id": "RES-NOV24-001", - "title": "Bryllupsfrisure", - "start": "2025-11-24T08:00:00Z", - "end": "2025-11-24T10:00:00Z", - "type": "customer", - "allDay": false, - "bookingId": "BOOK-NOV22-001", - "resourceId": "EMP001", - "customerId": "CUST001", - "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } - }, - { - "id": "RES-NOV24-002", - "title": "Ombre", - "start": "2025-11-24T10:00:00Z", - "end": "2025-11-24T12:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 150, "color": "pink" } - }, - { - "id": "RES-NOV24-003", - "title": "Fade klipning", - "start": "2025-11-24T11:00:00Z", - "end": "2025-11-24T11:45:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 45, "color": "indigo" } - }, - { - "id": "RES-NOV24-004", - "title": "Klipning og vask", - "start": "2025-11-24T14:00:00Z", - "end": "2025-11-24T15:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "teal" } - }, - { - "id": "RES-NOV24-005", - "title": "Grundklipning elev", - "start": "2025-11-24T13:00:00Z", - "end": "2025-11-24T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "light-green" } - }, - { - "id": "RES-NOV25-001", - "title": "Balayage kort hår", - "description": "Daily team sync - status updates", - "start": "2025-11-25T09:00:00Z", - "end": "2025-11-25T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP001", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "purple" } - }, - { - "id": "RES-NOV25-002", - "title": "Extensions", - "start": "2025-11-25T11:00:00Z", - "end": "2025-11-25T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 180, "color": "pink" } - }, - { - "id": "RES-NOV25-003", - "title": "Herreklipning + skæg", - "start": "2025-11-25T09:00:00Z", - "end": "2025-11-25T10:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "indigo" } - }, - { - "id": "RES-NOV25-004", - "title": "Styling special", - "start": "2025-11-25T15:00:00Z", - "end": "2025-11-25T16:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "teal" } - }, - { - "id": "RES-NOV25-005", - "title": "Praktik vask", - "start": "2025-11-25T10:00:00Z", - "end": "2025-11-25T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT002", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "orange" } - }, - { - "id": "RES-NOV26-001", - "title": "Farvekorrektion", - "start": "2025-11-26T09:00:00Z", - "end": "2025-11-26T12:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP001", - "syncStatus": "synced", - "metadata": { "duration": 180, "color": "purple" } - }, - { - "id": "RES-NOV26-002", - "title": "Keratinbehandling", - "start": "2025-11-26T10:00:00Z", - "end": "2025-11-26T12:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 150, "color": "pink" } - }, - { - "id": "RES-NOV26-003", - "title": "Skin fade", - "start": "2025-11-26T13:00:00Z", - "end": "2025-11-26T13:45:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 45, "color": "indigo" } - }, - { - "id": "RES-NOV26-004", - "title": "Dameklipning lang", - "start": "2025-11-26T14:00:00Z", - "end": "2025-11-26T15:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "teal" } - }, - { - "id": "RES-NOV26-005", - "title": "Føntørring træning", - "start": "2025-11-26T11:00:00Z", - "end": "2025-11-26T12:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "light-green" } - }, - { - "id": "RES-NOV27-001", - "title": "Full color", - "start": "2025-11-27T09:00:00Z", - "end": "2025-11-27T11:00:00Z", - "type": "customer", - "allDay": false, - "bookingId": "BOOK-NOV22-001", - "resourceId": "EMP001", - "customerId": "CUST001", - "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } - }, - { - "id": "RES-NOV27-002", - "title": "Babylights", - "start": "2025-11-27T12:00:00Z", - "end": "2025-11-27T15:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 180, "color": "pink" } - }, - { - "id": "RES-NOV27-003", - "title": "Klassisk herreklip", - "start": "2025-11-27T10:00:00Z", - "end": "2025-11-27T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "indigo" } - }, - { - "id": "RES-NOV27-004", - "title": "Klipning + styling", - "start": "2025-11-27T11:00:00Z", - "end": "2025-11-27T12:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "teal" } - }, - { - "id": "RES-NOV27-005", - "title": "Vask assistance", - "start": "2025-11-27T14:00:00Z", - "end": "2025-11-27T14:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "light-green" } - }, - { - "id": "RES-NOV27-006", - "title": "Observation", - "start": "2025-11-27T15:00:00Z", - "end": "2025-11-27T16:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT002", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "orange" } + "metadata": { + "duration": 1440, + "color": "#795548" + } } ] \ No newline at end of file diff --git a/wwwroot/data/mock-resources.json b/wwwroot/data/resources.json similarity index 100% rename from wwwroot/data/mock-resources.json rename to wwwroot/data/resources.json