From e581039b62569527ab7fb0e94fd662d1672d4bf2 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Mon, 8 Dec 2025 00:26:16 +0100 Subject: [PATCH] Refactors calendar data management and sync infrastructure Introduces comprehensive data management system for calendar V2 - Adds IndexedDB storage with pluggable entity services - Implements EventBus for decoupled event communication - Creates data seeding mechanism for initial application setup - Establishes sync and repository abstractions for flexible data handling --- src/v2/V2CompositionRoot.ts | 38 +++++ src/v2/constants/CoreEvents.ts | 47 ++++++ src/v2/core/EventBus.ts | 174 ++++++++++++++++++++ src/v2/demo/DemoApp.ts | 16 +- src/v2/demo/index.ts | 4 +- src/v2/repositories/IApiRepository.ts | 33 ++++ src/v2/repositories/MockEventRepository.ts | 86 ++++++++++ src/v2/storage/BaseEntityService.ts | 158 ++++++++++++++++++ src/v2/storage/IEntityService.ts | 38 +++++ src/v2/storage/IStore.ts | 18 ++ src/v2/storage/IndexedDBContext.ts | 92 +++++++++++ src/v2/storage/SyncPlugin.ts | 64 +++++++ src/v2/storage/events/EventSerialization.ts | 32 ++++ src/v2/storage/events/EventService.ts | 84 ++++++++++ src/v2/storage/events/EventStore.ts | 37 +++++ src/v2/types/CalendarTypes.ts | 86 ++++++++++ src/v2/workers/DataSeeder.ts | 73 ++++++++ 17 files changed, 1076 insertions(+), 4 deletions(-) create mode 100644 src/v2/constants/CoreEvents.ts create mode 100644 src/v2/core/EventBus.ts create mode 100644 src/v2/repositories/IApiRepository.ts create mode 100644 src/v2/repositories/MockEventRepository.ts create mode 100644 src/v2/storage/BaseEntityService.ts create mode 100644 src/v2/storage/IEntityService.ts create mode 100644 src/v2/storage/IStore.ts create mode 100644 src/v2/storage/IndexedDBContext.ts create mode 100644 src/v2/storage/SyncPlugin.ts create mode 100644 src/v2/storage/events/EventSerialization.ts create mode 100644 src/v2/storage/events/EventService.ts create mode 100644 src/v2/storage/events/EventStore.ts create mode 100644 src/v2/types/CalendarTypes.ts create mode 100644 src/v2/workers/DataSeeder.ts diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index 6c494d0..e168035 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -14,6 +14,24 @@ import { HeaderDrawerManager } from './core/HeaderDrawerManager'; import { MockTeamStore, MockResourceStore } from './demo/MockStores'; import { DemoApp } from './demo/DemoApp'; +// Event system +import { EventBus } from './core/EventBus'; +import { IEventBus, ICalendarEvent, ISync } from './types/CalendarTypes'; + +// Storage +import { IndexedDBContext } from './storage/IndexedDBContext'; +import { IStore } from './storage/IStore'; +import { IEntityService } from './storage/IEntityService'; +import { EventStore } from './storage/events/EventStore'; +import { EventService } from './storage/events/EventService'; + +// Repositories +import { IApiRepository } from './repositories/IApiRepository'; +import { MockEventRepository } from './repositories/MockEventRepository'; + +// Workers +import { DataSeeder } from './workers/DataSeeder'; + const defaultTimeFormatConfig: ITimeFormatConfig = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, use24HourFormat: true, @@ -29,9 +47,29 @@ export function createV2Container(): Container { // Config builder.registerInstance(defaultTimeFormatConfig).as(); + // Core - EventBus + builder.registerType(EventBus).as(); + builder.registerType(EventBus).as(); + // Services builder.registerType(DateService).as(); + // Storage infrastructure + builder.registerType(IndexedDBContext).as(); + builder.registerType(EventStore).as(); + + // Entity services + builder.registerType(EventService).as>(); + builder.registerType(EventService).as>(); + builder.registerType(EventService).as(); + + // Repositories + builder.registerType(MockEventRepository).as>(); + builder.registerType(MockEventRepository).as>(); + + // Workers + builder.registerType(DataSeeder).as(); + // Renderers - registreres som IGroupingRenderer builder.registerType(DateRenderer).as(); builder.registerType(ResourceRenderer).as(); diff --git a/src/v2/constants/CoreEvents.ts b/src/v2/constants/CoreEvents.ts new file mode 100644 index 0000000..d4f532f --- /dev/null +++ b/src/v2/constants/CoreEvents.ts @@ -0,0 +1,47 @@ +/** + * CoreEvents - Consolidated essential events for the calendar V2 + */ +export const CoreEvents = { + // Lifecycle events + INITIALIZED: 'core:initialized', + READY: 'core:ready', + DESTROYED: 'core:destroyed', + + // View events + VIEW_CHANGED: 'view:changed', + VIEW_RENDERED: 'view:rendered', + + // Navigation events + DATE_CHANGED: 'nav:date-changed', + NAVIGATION_COMPLETED: 'nav:navigation-completed', + + // Data events + DATA_LOADING: 'data:loading', + DATA_LOADED: 'data:loaded', + DATA_ERROR: 'data:error', + + // Grid events + GRID_RENDERED: 'grid:rendered', + GRID_CLICKED: 'grid:clicked', + + // Event management + EVENT_CREATED: 'event:created', + EVENT_UPDATED: 'event:updated', + EVENT_DELETED: 'event:deleted', + EVENT_SELECTED: 'event:selected', + + // System events + ERROR: 'system:error', + + // Sync events + SYNC_STARTED: 'sync:started', + SYNC_COMPLETED: 'sync:completed', + SYNC_FAILED: 'sync:failed', + + // Entity events - for audit and sync + ENTITY_SAVED: 'entity:saved', + ENTITY_DELETED: 'entity:deleted', + + // Rendering events + EVENTS_RENDERED: 'events:rendered' +} as const; diff --git a/src/v2/core/EventBus.ts b/src/v2/core/EventBus.ts new file mode 100644 index 0000000..469a73e --- /dev/null +++ b/src/v2/core/EventBus.ts @@ -0,0 +1,174 @@ +import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes'; + +/** + * Central event dispatcher for calendar using DOM CustomEvents + * Provides logging and debugging capabilities + */ +export class EventBus implements IEventBus { + private eventLog: IEventLogEntry[] = []; + private debug: boolean = false; + private listeners: Set = new Set(); + + // Log configuration for different categories + private logConfig: { [key: string]: boolean } = { + calendar: true, + grid: true, + event: true, + scroll: true, + navigation: true, + view: true, + default: true + }; + + /** + * Subscribe to an event via DOM addEventListener + */ + on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void { + document.addEventListener(eventType, handler, options); + + // Track for cleanup + this.listeners.add({ eventType, handler, options }); + + // Return unsubscribe function + return () => this.off(eventType, handler); + } + + /** + * Subscribe to an event once + */ + once(eventType: string, handler: EventListener): () => void { + return this.on(eventType, handler, { once: true }); + } + + /** + * Unsubscribe from an event + */ + off(eventType: string, handler: EventListener): void { + document.removeEventListener(eventType, handler); + + // Remove from tracking + for (const listener of this.listeners) { + if (listener.eventType === eventType && listener.handler === handler) { + this.listeners.delete(listener); + break; + } + } + } + + /** + * Emit an event via DOM CustomEvent + */ + emit(eventType: string, detail: unknown = {}): boolean { + // Validate eventType + if (!eventType) { + return false; + } + + const event = new CustomEvent(eventType, { + detail: detail ?? {}, + bubbles: true, + cancelable: true + }); + + // Log event with grouping + if (this.debug) { + this.logEventWithGrouping(eventType, detail); + } + + this.eventLog.push({ + type: eventType, + detail: detail ?? {}, + timestamp: Date.now() + }); + + // Emit on document (only DOM events now) + return !document.dispatchEvent(event); + } + + /** + * Log event with console grouping + */ + private logEventWithGrouping(eventType: string, _detail: unknown): void { + // Extract category from event type (e.g., 'calendar:datechanged' → 'calendar') + const category = this.extractCategory(eventType); + + // Only log if category is enabled + if (!this.logConfig[category]) { + return; + } + + // Get category emoji and color (used for future console styling) + this.getCategoryStyle(category); + } + + /** + * Extract category from event type + */ + private extractCategory(eventType: string): string { + if (!eventType) { + return 'unknown'; + } + + if (eventType.includes(':')) { + return eventType.split(':')[0]; + } + + // Fallback: try to detect category from event name patterns + const lowerType = eventType.toLowerCase(); + if (lowerType.includes('grid') || lowerType.includes('rendered')) return 'grid'; + if (lowerType.includes('event') || lowerType.includes('sync')) return 'event'; + if (lowerType.includes('scroll')) return 'scroll'; + if (lowerType.includes('nav') || lowerType.includes('date')) return 'navigation'; + if (lowerType.includes('view')) return 'view'; + + return 'default'; + } + + /** + * Get styling for different categories + */ + private getCategoryStyle(category: string): { emoji: string; color: string } { + const styles: { [key: string]: { emoji: string; color: string } } = { + calendar: { emoji: '📅', color: '#2196F3' }, + grid: { emoji: '📊', color: '#4CAF50' }, + event: { emoji: '📌', color: '#FF9800' }, + scroll: { emoji: '📜', color: '#9C27B0' }, + navigation: { emoji: '🧭', color: '#F44336' }, + view: { emoji: '👁', color: '#00BCD4' }, + default: { emoji: '📢', color: '#607D8B' } + }; + + return styles[category] || styles.default; + } + + /** + * Configure logging for specific categories + */ + setLogConfig(config: { [key: string]: boolean }): void { + this.logConfig = { ...this.logConfig, ...config }; + } + + /** + * Get current log configuration + */ + getLogConfig(): { [key: string]: boolean } { + return { ...this.logConfig }; + } + + /** + * Get event history + */ + getEventLog(eventType?: string): IEventLogEntry[] { + if (eventType) { + return this.eventLog.filter(e => e.type === eventType); + } + return this.eventLog; + } + + /** + * Enable/disable debug mode + */ + setDebug(enabled: boolean): void { + this.debug = enabled; + } +} diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index 31c69bd..5e49265 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -5,6 +5,8 @@ import { DateService } from '../core/DateService'; import { ScrollManager } from '../core/ScrollManager'; import { HeaderDrawerManager } from '../core/HeaderDrawerManager'; import { ViewConfig } from '../core/ViewConfig'; +import { IndexedDBContext } from '../storage/IndexedDBContext'; +import { DataSeeder } from '../workers/DataSeeder'; export class DemoApp { private animator!: NavigationAnimator; @@ -17,10 +19,20 @@ export class DemoApp { private timeAxisRenderer: TimeAxisRenderer, private dateService: DateService, private scrollManager: ScrollManager, - private headerDrawerManager: HeaderDrawerManager + private headerDrawerManager: HeaderDrawerManager, + private indexedDBContext: IndexedDBContext, + private dataSeeder: DataSeeder ) {} - init(): void { + async init(): Promise { + // Initialize IndexedDB + await this.indexedDBContext.initialize(); + console.log('[DemoApp] IndexedDB initialized'); + + // Seed data if empty + await this.dataSeeder.seedIfEmpty(); + console.log('[DemoApp] Data seeding complete'); + this.container = document.querySelector('swp-calendar-container') as HTMLElement; // NavigationAnimator har DOM-dependencies - tilladt med new diff --git a/src/v2/demo/index.ts b/src/v2/demo/index.ts index ca25d48..afbabf3 100644 --- a/src/v2/demo/index.ts +++ b/src/v2/demo/index.ts @@ -1,5 +1,5 @@ import { createV2Container } from '../V2CompositionRoot'; import { DemoApp } from './DemoApp'; -const app = createV2Container(); -app.resolveType().init(); +const container = createV2Container(); +container.resolveType().init().catch(console.error); diff --git a/src/v2/repositories/IApiRepository.ts b/src/v2/repositories/IApiRepository.ts new file mode 100644 index 0000000..a50791f --- /dev/null +++ b/src/v2/repositories/IApiRepository.ts @@ -0,0 +1,33 @@ +import { EntityType } from '../types/CalendarTypes'; + +/** + * IApiRepository - Generic interface for backend API communication + * + * Used by DataSeeder to fetch initial data and by SyncManager for sync operations. + */ +export interface IApiRepository { + /** + * Entity type discriminator - used for runtime routing + */ + readonly entityType: EntityType; + + /** + * Send create operation to backend API + */ + sendCreate(data: T): Promise; + + /** + * Send update operation to backend API + */ + sendUpdate(id: string, updates: Partial): Promise; + + /** + * Send delete operation to backend API + */ + sendDelete(id: string): Promise; + + /** + * Fetch all entities from backend API + */ + fetchAll(): Promise; +} diff --git a/src/v2/repositories/MockEventRepository.ts b/src/v2/repositories/MockEventRepository.ts new file mode 100644 index 0000000..939569b --- /dev/null +++ b/src/v2/repositories/MockEventRepository.ts @@ -0,0 +1,86 @@ +import { ICalendarEvent, EntityType, CalendarEventType } from '../types/CalendarTypes'; +import { IApiRepository } from './IApiRepository'; + +interface RawEventData { + id: string; + title: string; + start: string | Date; + end: string | Date; + type: string; + allDay?: boolean; + bookingId?: string; + resourceId?: string; + customerId?: string; + description?: string; + recurringId?: string; + metadata?: Record; + [key: string]: unknown; +} + +/** + * MockEventRepository - Loads event data from local JSON file + * + * Used for development and testing. Only fetchAll() is implemented. + */ +export class MockEventRepository implements IApiRepository { + public readonly entityType: EntityType = 'Event'; + private readonly dataUrl = 'data/mock-events.json'; + + /** + * Fetch all events 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 events: ${response.status} ${response.statusText}`); + } + + const rawData: RawEventData[] = await response.json(); + return this.processCalendarData(rawData); + } catch (error) { + console.error('Failed to load event data:', error); + throw error; + } + } + + public async sendCreate(_event: ICalendarEvent): Promise { + throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.'); + } + + public async sendUpdate(_id: string, _updates: Partial): Promise { + throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.'); + } + + public async sendDelete(_id: string): Promise { + throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.'); + } + + private processCalendarData(data: RawEventData[]): ICalendarEvent[] { + return data.map((event): ICalendarEvent => { + // Validate customer event 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, + bookingId: event.bookingId, + resourceId: event.resourceId, + customerId: event.customerId, + recurringId: event.recurringId, + metadata: event.metadata, + syncStatus: 'synced' as const + }; + }); + } +} diff --git a/src/v2/storage/BaseEntityService.ts b/src/v2/storage/BaseEntityService.ts new file mode 100644 index 0000000..cd4cbeb --- /dev/null +++ b/src/v2/storage/BaseEntityService.ts @@ -0,0 +1,158 @@ +import { ISync, EntityType, SyncStatus, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../types/CalendarTypes'; +import { IEntityService } from './IEntityService'; +import { SyncPlugin } from './SyncPlugin'; +import { IndexedDBContext } from './IndexedDBContext'; +import { CoreEvents } from '../constants/CoreEvents'; + +/** + * BaseEntityService - Abstract base class for all entity services + * + * PROVIDES: + * - Generic CRUD operations (get, getAll, save, delete) + * - Sync status management (delegates to SyncPlugin) + * - Serialization hooks (override in subclass if needed) + */ +export abstract class BaseEntityService implements IEntityService { + abstract readonly storeName: string; + abstract readonly entityType: EntityType; + + private syncPlugin: SyncPlugin; + private context: IndexedDBContext; + protected eventBus: IEventBus; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + this.context = context; + this.eventBus = eventBus; + this.syncPlugin = new SyncPlugin(this); + } + + protected get db(): IDBDatabase { + return this.context.getDatabase(); + } + + /** + * Serialize entity before storing in IndexedDB + */ + protected serialize(entity: T): unknown { + return entity; + } + + /** + * Deserialize data from IndexedDB back to entity + */ + protected deserialize(data: unknown): T { + return data as T; + } + + /** + * Get a single entity by ID + */ + async get(id: string): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const request = store.get(id); + + request.onsuccess = () => { + const data = request.result; + resolve(data ? this.deserialize(data) : null); + }; + + request.onerror = () => { + reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`)); + }; + }); + } + + /** + * Get all entities + */ + async getAll(): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const request = store.getAll(); + + request.onsuccess = () => { + const data = request.result as unknown[]; + const entities = data.map(item => this.deserialize(item)); + resolve(entities); + }; + + request.onerror = () => { + reject(new Error(`Failed to get all ${this.entityType}s: ${request.error}`)); + }; + }); + } + + /** + * Save an entity (create or update) + */ + async save(entity: T): Promise { + const entityId = (entity as unknown as { id: string }).id; + const existingEntity = await this.get(entityId); + const isNew = existingEntity === null; + 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 = () => { + const payload: IEntitySavedPayload = { + entityType: this.entityType, + entity, + isNew + }; + this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload); + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`)); + }; + }); + } + + /** + * Delete an entity + */ + async delete(id: string): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + const request = store.delete(id); + + request.onsuccess = () => { + const payload: IEntityDeletedPayload = { + entityType: this.entityType, + id + }; + this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload); + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`)); + }; + }); + } + + // Sync methods - delegate to SyncPlugin + async markAsSynced(id: string): Promise { + return this.syncPlugin.markAsSynced(id); + } + + async markAsError(id: string): Promise { + return this.syncPlugin.markAsError(id); + } + + async getSyncStatus(id: string): Promise { + return this.syncPlugin.getSyncStatus(id); + } + + async getBySyncStatus(syncStatus: string): Promise { + return this.syncPlugin.getBySyncStatus(syncStatus); + } +} diff --git a/src/v2/storage/IEntityService.ts b/src/v2/storage/IEntityService.ts new file mode 100644 index 0000000..be18ce4 --- /dev/null +++ b/src/v2/storage/IEntityService.ts @@ -0,0 +1,38 @@ +import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes'; + +/** + * IEntityService - Generic interface for entity services with sync capabilities + * + * All entity services implement this interface to enable polymorphic operations. + */ +export interface IEntityService { + /** + * Entity type discriminator for runtime routing + */ + readonly entityType: EntityType; + + /** + * Get all entities from IndexedDB + */ + getAll(): Promise; + + /** + * Save an entity (create or update) to IndexedDB + */ + save(entity: T): Promise; + + /** + * Mark entity as successfully synced + */ + markAsSynced(id: string): Promise; + + /** + * Mark entity as sync error + */ + markAsError(id: string): Promise; + + /** + * Get current sync status for an entity + */ + getSyncStatus(id: string): Promise; +} diff --git a/src/v2/storage/IStore.ts b/src/v2/storage/IStore.ts new file mode 100644 index 0000000..91ac873 --- /dev/null +++ b/src/v2/storage/IStore.ts @@ -0,0 +1,18 @@ +/** + * IStore - Interface for IndexedDB ObjectStore definitions + * + * Each entity store implements this interface to define its schema. + * Enables Open/Closed Principle: IndexedDBContext works with any IStore. + */ +export interface IStore { + /** + * The name of the ObjectStore in IndexedDB + */ + readonly storeName: string; + + /** + * Create the ObjectStore with its schema (indexes, keyPath, etc.) + * Called during database upgrade (onupgradeneeded event) + */ + create(db: IDBDatabase): void; +} diff --git a/src/v2/storage/IndexedDBContext.ts b/src/v2/storage/IndexedDBContext.ts new file mode 100644 index 0000000..a504cf3 --- /dev/null +++ b/src/v2/storage/IndexedDBContext.ts @@ -0,0 +1,92 @@ +import { IStore } from './IStore'; + +/** + * IndexedDBContext - Database connection manager + * + * RESPONSIBILITY: + * - Opens and manages IDBDatabase connection lifecycle + * - Creates object stores via injected IStore implementations + * - Provides shared IDBDatabase instance to all services + */ +export class IndexedDBContext { + private static readonly DB_NAME = 'CalendarV2DB'; + private static readonly DB_VERSION = 1; + + private db: IDBDatabase | null = null; + private initialized: boolean = false; + private stores: IStore[]; + + constructor(stores: IStore[]) { + this.stores = stores; + } + + /** + * Initialize and open the database + */ + 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 + this.stores.forEach(store => { + if (!db.objectStoreNames.contains(store.storeName)) { + store.create(db); + } + }); + }; + }); + } + + /** + * Check if database is initialized + */ + public isInitialized(): boolean { + return this.initialized; + } + + /** + * Get 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/v2/storage/SyncPlugin.ts b/src/v2/storage/SyncPlugin.ts new file mode 100644 index 0000000..7774da6 --- /dev/null +++ b/src/v2/storage/SyncPlugin.ts @@ -0,0 +1,64 @@ +import { ISync, SyncStatus } from '../types/CalendarTypes'; + +/** + * SyncPlugin - Pluggable sync functionality for entity services + * + * COMPOSITION PATTERN: + * - Encapsulates all sync-related logic in separate class + * - Composed into BaseEntityService (not inheritance) + */ +export class SyncPlugin { + constructor(private service: any) {} + + /** + * Mark entity as successfully synced + */ + async markAsSynced(id: string): Promise { + const entity = await this.service.get(id); + if (entity) { + entity.syncStatus = 'synced'; + await this.service.save(entity); + } + } + + /** + * Mark entity as sync error + */ + async markAsError(id: string): Promise { + const entity = await this.service.get(id); + if (entity) { + entity.syncStatus = 'error'; + await this.service.save(entity); + } + } + + /** + * Get current sync status for an entity + */ + async getSyncStatus(id: string): Promise { + const entity = await this.service.get(id); + return entity ? entity.syncStatus : null; + } + + /** + * Get entities by sync status using IndexedDB index + */ + async getBySyncStatus(syncStatus: string): Promise { + return new Promise((resolve, reject) => { + const transaction = this.service.db.transaction([this.service.storeName], 'readonly'); + const store = transaction.objectStore(this.service.storeName); + const index = store.index('syncStatus'); + const request = index.getAll(syncStatus); + + request.onsuccess = () => { + const data = request.result as unknown[]; + const entities = data.map(item => this.service.deserialize(item)); + resolve(entities); + }; + + request.onerror = () => { + reject(new Error(`Failed to get by sync status ${syncStatus}: ${request.error}`)); + }; + }); + } +} diff --git a/src/v2/storage/events/EventSerialization.ts b/src/v2/storage/events/EventSerialization.ts new file mode 100644 index 0000000..583fa79 --- /dev/null +++ b/src/v2/storage/events/EventSerialization.ts @@ -0,0 +1,32 @@ +import { ICalendarEvent } from '../../types/CalendarTypes'; + +/** + * EventSerialization - Handles Date field serialization for IndexedDB + * + * IndexedDB doesn't store Date objects directly, so we convert: + * - Date → ISO string (serialize) when writing + * - ISO string → Date (deserialize) when reading + */ +export class EventSerialization { + /** + * Serialize event for IndexedDB storage + */ + static serialize(event: ICalendarEvent): unknown { + return { + ...event, + start: event.start instanceof Date ? event.start.toISOString() : event.start, + end: event.end instanceof Date ? event.end.toISOString() : event.end + }; + } + + /** + * Deserialize event from IndexedDB storage + */ + static deserialize(data: Record): ICalendarEvent { + return { + ...data, + start: typeof data.start === 'string' ? new Date(data.start) : data.start, + end: typeof data.end === 'string' ? new Date(data.end) : data.end + } as ICalendarEvent; + } +} diff --git a/src/v2/storage/events/EventService.ts b/src/v2/storage/events/EventService.ts new file mode 100644 index 0000000..0ccd5a5 --- /dev/null +++ b/src/v2/storage/events/EventService.ts @@ -0,0 +1,84 @@ +import { ICalendarEvent, EntityType, IEventBus } 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 + * + * Extends BaseEntityService for shared CRUD and sync logic. + * Provides event-specific query methods. + */ +export class EventService extends BaseEntityService { + readonly storeName = EventStore.STORE_NAME; + readonly entityType: EntityType = 'Event'; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + + protected serialize(event: ICalendarEvent): unknown { + return EventSerialization.serialize(event); + } + + protected deserialize(data: unknown): ICalendarEvent { + return EventSerialization.deserialize(data as Record); + } + + /** + * Get events within a date range + */ + async getByDateRange(start: Date, end: Date): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const index = store.index('start'); + + const range = IDBKeyRange.lowerBound(start.toISOString()); + const request = index.getAll(range); + + request.onsuccess = () => { + const data = request.result as unknown[]; + const events = data + .map(item => this.deserialize(item)) + .filter(event => event.start <= end); + resolve(events); + }; + + request.onerror = () => { + reject(new Error(`Failed to get events by date range: ${request.error}`)); + }; + }); + } + + /** + * Get events for a specific resource + */ + async getByResource(resourceId: 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('resourceId'); + const request = index.getAll(resourceId); + + request.onsuccess = () => { + const data = request.result as unknown[]; + const events = data.map(item => this.deserialize(item)); + resolve(events); + }; + + request.onerror = () => { + reject(new Error(`Failed to get events for resource ${resourceId}: ${request.error}`)); + }; + }); + } + + /** + * Get events for a resource within a date range + */ + async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise { + const resourceEvents = await this.getByResource(resourceId); + return resourceEvents.filter(event => event.start >= start && event.start <= end); + } +} diff --git a/src/v2/storage/events/EventStore.ts b/src/v2/storage/events/EventStore.ts new file mode 100644 index 0000000..21c7be0 --- /dev/null +++ b/src/v2/storage/events/EventStore.ts @@ -0,0 +1,37 @@ +import { IStore } from '../IStore'; + +/** + * EventStore - IndexedDB ObjectStore definition for calendar events + */ +export class EventStore implements IStore { + static readonly STORE_NAME = 'events'; + readonly storeName = EventStore.STORE_NAME; + + /** + * Create the events ObjectStore with indexes + */ + create(db: IDBDatabase): void { + const store = db.createObjectStore(EventStore.STORE_NAME, { keyPath: 'id' }); + + // Index: start (for date range queries) + store.createIndex('start', 'start', { unique: false }); + + // Index: end (for date range queries) + store.createIndex('end', 'end', { unique: false }); + + // Index: syncStatus (for filtering by sync state) + store.createIndex('syncStatus', 'syncStatus', { unique: false }); + + // Index: resourceId (for resource-mode filtering) + store.createIndex('resourceId', 'resourceId', { unique: false }); + + // Index: customerId (for customer-centric queries) + store.createIndex('customerId', 'customerId', { unique: false }); + + // Index: bookingId (for event-to-booking lookups) + store.createIndex('bookingId', 'bookingId', { unique: false }); + + // Compound index: startEnd (for optimized range queries) + store.createIndex('startEnd', ['start', 'end'], { unique: false }); + } +} diff --git a/src/v2/types/CalendarTypes.ts b/src/v2/types/CalendarTypes.ts new file mode 100644 index 0000000..fdf1d97 --- /dev/null +++ b/src/v2/types/CalendarTypes.ts @@ -0,0 +1,86 @@ +/** + * Calendar V2 Type Definitions + */ + +export type SyncStatus = 'synced' | 'pending' | 'error'; + +export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit'; + +/** + * CalendarEventType - Used by ICalendarEvent.type + * Note: Only 'customer' events have associated IBooking + */ +export type CalendarEventType = + | 'customer' // Customer appointment (HAS booking) + | 'vacation' // Vacation/time off (NO booking) + | 'break' // Lunch/break (NO booking) + | 'meeting' // Meeting (NO booking) + | 'blocked'; // Blocked time (NO booking) + +/** + * ISync - Interface for sync status tracking + * All syncable entities should extend this interface + */ +export interface ISync { + syncStatus: SyncStatus; +} + +/** + * IDataEntity - Wrapper for entity data with typename discriminator + */ +export interface IDataEntity { + typename: EntityType; + data: unknown; +} + +export interface ICalendarEvent extends ISync { + id: string; + title: string; + description?: string; + start: Date; + end: Date; + type: CalendarEventType; + allDay: boolean; + + // References (denormalized for IndexedDB performance) + bookingId?: string; // Reference to booking (only if type = 'customer') + resourceId?: string; // Resource who owns this time slot + customerId?: string; // Denormalized from Booking.customerId + + recurringId?: string; + metadata?: Record; +} + +// EventBus types +export interface IEventLogEntry { + type: string; + detail: unknown; + timestamp: number; +} + +export interface IListenerEntry { + eventType: string; + handler: EventListener; + options?: AddEventListenerOptions; +} + +export interface IEventBus { + on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void; + once(eventType: string, handler: EventListener): () => void; + off(eventType: string, handler: EventListener): void; + emit(eventType: string, detail?: unknown): boolean; + getEventLog(eventType?: string): IEventLogEntry[]; + setDebug(enabled: boolean): void; +} + +// Entity event payloads +export interface IEntitySavedPayload { + entityType: EntityType; + entity: ISync; + isNew: boolean; +} + +export interface IEntityDeletedPayload { + entityType: EntityType; + id: string; +} diff --git a/src/v2/workers/DataSeeder.ts b/src/v2/workers/DataSeeder.ts new file mode 100644 index 0000000..62aada8 --- /dev/null +++ b/src/v2/workers/DataSeeder.ts @@ -0,0 +1,73 @@ +import { IApiRepository } from '../repositories/IApiRepository'; +import { IEntityService } from '../storage/IEntityService'; +import { ISync } from '../types/CalendarTypes'; + +/** + * 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 + * + * 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 + */ +export class DataSeeder { + constructor( + private services: IEntityService[], + private repositories: IApiRepository[] + ) {} + + /** + * Seed all entity stores if they are empty + */ + async seedIfEmpty(): Promise { + console.log('[DataSeeder] Checking if database needs seeding...'); + + try { + for (const service of this.services) { + 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; + } + + await this.seedEntity(service.entityType, service, repository); + } + + console.log('[DataSeeder] Seeding complete'); + } catch (error) { + console.error('[DataSeeder] Seeding failed:', error); + throw error; + } + } + + private async seedEntity( + entityType: string, + service: IEntityService, + repository: IApiRepository + ): Promise { + 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...`); + + const data = await repository.fetchAll(); + + console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`); + + for (const entity of data) { + await service.save(entity); + } + + console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`); + } +}