diff --git a/src/constants/CoreEvents.ts b/src/constants/CoreEvents.ts index 7051565..8105bea 100644 --- a/src/constants/CoreEvents.ts +++ b/src/constants/CoreEvents.ts @@ -19,11 +19,12 @@ export const CoreEvents = { PERIOD_INFO_UPDATE: 'nav:period-info-update', NAVIGATE_TO_EVENT: 'nav:navigate-to-event', - // Data events (4) + // Data events (5) DATA_LOADING: 'data:loading', DATA_LOADED: 'data:loaded', DATA_ERROR: 'data:error', EVENTS_FILTERED: 'data:events-filtered', + REMOTE_UPDATE_RECEIVED: 'data:remote-update', // Grid events (3) GRID_RENDERED: 'grid:rendered', @@ -36,9 +37,16 @@ export const CoreEvents = { EVENT_DELETED: 'event:deleted', EVENT_SELECTED: 'event:selected', - // System events (2) + // System events (3) ERROR: 'system:error', REFRESH_REQUESTED: 'system:refresh', + OFFLINE_MODE_CHANGED: 'system:offline-mode-changed', + + // Sync events (4) + SYNC_STARTED: 'sync:started', + SYNC_COMPLETED: 'sync:completed', + SYNC_FAILED: 'sync:failed', + SYNC_RETRY: 'sync:retry', // Filter events (1) FILTER_CHANGED: 'filter:changed', diff --git a/src/index.ts b/src/index.ts index 908e0fc..5d093d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,9 +21,16 @@ import { EdgeScrollManager } from './managers/EdgeScrollManager'; import { DragHoverManager } from './managers/DragHoverManager'; import { HeaderManager } from './managers/HeaderManager'; -// Import repositories +// Import repositories and storage import { IEventRepository } from './repositories/IEventRepository'; import { MockEventRepository } from './repositories/MockEventRepository'; +import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository'; +import { ApiEventRepository } from './repositories/ApiEventRepository'; +import { IndexedDBService } from './storage/IndexedDBService'; +import { OperationQueue } from './storage/OperationQueue'; + +// Import workers +import { SyncManager } from './workers/SyncManager'; // Import renderers import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer'; @@ -53,8 +60,8 @@ async function handleDeepLinking(eventManager: EventManager, urlManager: URLMana console.log(`Deep linking to event ID: ${eventId}`); // Wait a bit for managers to be fully ready - setTimeout(() => { - const success = eventManager.navigateToEvent(eventId); + setTimeout(async () => { + const success = await eventManager.navigateToEvent(eventId); if (!success) { console.warn(`Deep linking failed: Event with ID ${eventId} not found`); } @@ -73,6 +80,22 @@ async function initializeCalendar(): Promise { // Load configuration from JSON const config = await ConfigManager.load(); + // ======================================== + // Initialize IndexedDB and seed if needed + // ======================================== + const indexedDB = new IndexedDBService(); + await indexedDB.initialize(); + await indexedDB.seedIfEmpty(); + + // Create operation queue + const queue = new OperationQueue(indexedDB); + + // Create API repository (placeholder for now) + const apiRepository = new ApiEventRepository(config.apiEndpoint || '/api'); + + // Create IndexedDB repository + const repository = new IndexedDBEventRepository(indexedDB, queue); + // Create NovaDI container const container = new Container(); const builder = container.builder(); @@ -86,8 +109,13 @@ async function initializeCalendar(): Promise { // Register configuration instance builder.registerInstance(config).as(); - // Register repositories - builder.registerType(MockEventRepository).as(); + // Register IndexedDB and storage instances + builder.registerInstance(indexedDB).as(); + builder.registerInstance(queue).as(); + builder.registerInstance(apiRepository).as(); + + // Register repository + builder.registerInstance(repository).as(); // Register renderers builder.registerType(DateHeaderRenderer).as(); @@ -143,6 +171,13 @@ async function initializeCalendar(): Promise { await calendarManager.initialize?.(); await resizeHandleManager.initialize?.(); + // ======================================== + // Initialize and start SyncManager + // ======================================== + const syncManager = new SyncManager(eventBus, queue, indexedDB, apiRepository); + syncManager.startSync(); + console.log('SyncManager initialized and started'); + // Handle deep linking after managers are initialized await handleDeepLinking(eventManager, urlManager); @@ -153,12 +188,18 @@ async function initializeCalendar(): Promise { app: typeof app; calendarManager: typeof calendarManager; eventManager: typeof eventManager; + syncManager: typeof syncManager; + indexedDB: typeof indexedDB; + queue: typeof queue; }; }).calendarDebug = { eventBus, app, calendarManager, eventManager, + syncManager, + indexedDB, + queue, }; } catch (error) { diff --git a/src/managers/AllDayManager.ts b/src/managers/AllDayManager.ts index 9025b9f..632190c 100644 --- a/src/managers/AllDayManager.ts +++ b/src/managers/AllDayManager.ts @@ -127,13 +127,13 @@ export class AllDayManager { }); // Listen for header ready - when dates are populated with period data - eventBus.on('header:ready', (event: Event) => { + eventBus.on('header:ready', async (event: Event) => { let headerReadyEventPayload = (event as CustomEvent).detail; let startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.date); let endDate = new Date(headerReadyEventPayload.headerElements.at(-1)!.date); - let events: ICalendarEvent[] = this.eventManager.getEventsForPeriod(startDate, endDate); + let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate); // Filter for all-day events const allDayEvents = events.filter(event => event.allDay); @@ -380,7 +380,7 @@ export class AllDayManager { } - private handleDragEnd(dragEndEvent: IDragEndEventPayload): void { + private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise { const getEventDurationDays = (start: string | undefined, end: string | undefined): number => { @@ -496,6 +496,15 @@ export class AllDayManager { // 7. Apply highlight class to show the dropped event with highlight color dragEndEvent.draggedClone.classList.add('highlight'); + // 8. CRITICAL FIX: Update event in repository to mark as allDay=true + // This ensures EventManager's repository has correct state + // Without this, the event will reappear in grid on re-render + await this.eventManager.updateEvent(eventId, { + start: newStartDate, + end: newEndDate, + allDay: true + }); + this.fadeOutAndRemove(dragEndEvent.originalElement); this.checkAndAnimateAllDayHeight(); diff --git a/src/managers/CalendarManager.ts b/src/managers/CalendarManager.ts index 3504acc..6bfcb80 100644 --- a/src/managers/CalendarManager.ts +++ b/src/managers/CalendarManager.ts @@ -211,7 +211,7 @@ export class CalendarManager { /** * Re-render events after grid structure changes */ - private rerenderEvents(): void { + private async rerenderEvents(): Promise { // Get current period data to determine date range const periodData = this.calculateCurrentPeriod(); @@ -223,7 +223,7 @@ export class CalendarManager { } // Trigger event rendering for the current date range using correct method - this.eventRenderer.renderEvents({ + await this.eventRenderer.renderEvents({ container: container as HTMLElement, startDate: new Date(periodData.start), endDate: new Date(periodData.end) diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts index a52361e..82605c5 100644 --- a/src/managers/EventManager.ts +++ b/src/managers/EventManager.ts @@ -6,11 +6,11 @@ import { IEventRepository } from '../repositories/IEventRepository'; /** * EventManager - Event lifecycle and CRUD operations - * Handles event management and CRUD operations + * Delegates all data operations to IEventRepository + * No longer maintains in-memory cache - repository is single source of truth */ export class EventManager { - private events: ICalendarEvent[] = []; private dateService: DateService; private config: Configuration; private repository: IEventRepository; @@ -28,30 +28,32 @@ export class EventManager { /** * Load event data from repository + * No longer caches - delegates to repository */ public async loadData(): Promise { try { - this.events = await this.repository.loadEvents(); + // Just ensure repository is ready - no caching + await this.repository.loadEvents(); } catch (error) { console.error('Failed to load event data:', error); - this.events = []; throw error; } } /** - * Get events with optional copying for performance + * Get all events from repository */ - public getEvents(copy: boolean = false): ICalendarEvent[] { - return copy ? [...this.events] : this.events; + public async getEvents(copy: boolean = false): Promise { + const events = await this.repository.loadEvents(); + return copy ? [...events] : events; } /** - * Optimized event lookup with early return + * Get event by ID from repository */ - public getEventById(id: string): ICalendarEvent | undefined { - // Use find for better performance than filter + first - return this.events.find(event => event.id === id); + public async getEventById(id: string): Promise { + const events = await this.repository.loadEvents(); + return events.find(event => event.id === id); } /** @@ -59,8 +61,8 @@ export class EventManager { * @param id Event ID to find * @returns Event with navigation info or null if not found */ - public getEventForNavigation(id: string): { event: ICalendarEvent; eventDate: Date } | null { - const event = this.getEventById(id); + public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> { + const event = await this.getEventById(id); if (!event) { return null; } @@ -90,8 +92,8 @@ export class EventManager { * @param eventId Event ID to navigate to * @returns true if event found and navigation initiated, false otherwise */ - public navigateToEvent(eventId: string): boolean { - const eventInfo = this.getEventForNavigation(eventId); + public async navigateToEvent(eventId: string): Promise { + const eventInfo = await this.getEventForNavigation(eventId); if (!eventInfo) { console.warn(`EventManager: Event with ID ${eventId} not found`); return false; @@ -113,23 +115,20 @@ export class EventManager { /** * Get events that overlap with a given time period */ - public getEventsForPeriod(startDate: Date, endDate: Date): ICalendarEvent[] { + public async getEventsForPeriod(startDate: Date, endDate: Date): Promise { + const events = await this.repository.loadEvents(); // Event overlaps period if it starts before period ends AND ends after period starts - return this.events.filter(event => { + return events.filter(event => { return event.start <= endDate && event.end >= startDate; }); } /** * Create a new event and add it to the calendar + * Delegates to repository with source='local' */ - public addEvent(event: Omit): ICalendarEvent { - const newEvent: ICalendarEvent = { - ...event, - id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` - }; - - this.events.push(newEvent); + public async addEvent(event: Omit): Promise { + const newEvent = await this.repository.createEvent(event, 'local'); this.eventBus.emit(CoreEvents.EVENT_CREATED, { event: newEvent @@ -140,18 +139,59 @@ export class EventManager { /** * Update an existing event + * Delegates to repository with source='local' */ - public updateEvent(id: string, updates: Partial): ICalendarEvent | null { - const eventIndex = this.events.findIndex(event => event.id === id); - if (eventIndex === -1) return null; + public async updateEvent(id: string, updates: Partial): Promise { + try { + const updatedEvent = await this.repository.updateEvent(id, updates, 'local'); - const updatedEvent = { ...this.events[eventIndex], ...updates }; - this.events[eventIndex] = updatedEvent; + this.eventBus.emit(CoreEvents.EVENT_UPDATED, { + event: updatedEvent + }); - this.eventBus.emit(CoreEvents.EVENT_UPDATED, { - event: updatedEvent - }); + return updatedEvent; + } catch (error) { + console.error(`Failed to update event ${id}:`, error); + return null; + } + } - return updatedEvent; + /** + * Delete an event + * Delegates to repository with source='local' + */ + public async deleteEvent(id: string): Promise { + try { + await this.repository.deleteEvent(id, 'local'); + + this.eventBus.emit(CoreEvents.EVENT_DELETED, { + eventId: id + }); + + return true; + } catch (error) { + console.error(`Failed to delete event ${id}:`, error); + 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/renderers/EventRendererManager.ts b/src/renderers/EventRendererManager.ts index 49260c7..deebf33 100644 --- a/src/renderers/EventRendererManager.ts +++ b/src/renderers/EventRendererManager.ts @@ -36,12 +36,12 @@ export class EventRenderingService { /** * Render events in a specific container for a given period */ - public renderEvents(context: IRenderContext): void { + 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 = this.eventManager.getEventsForPeriod( + const events = await this.eventManager.getEventsForPeriod( context.startDate, context.endDate ); @@ -159,7 +159,7 @@ export class EventRenderingService { } private setupDragEndListener(): void { - this.eventBus.on('drag:end', (event: Event) => { + this.eventBus.on('drag:end', async (event: Event) => { const { originalElement: draggedElement, sourceColumn, finalPosition, target } = (event as CustomEvent).detail; const finalColumn = finalPosition.column; @@ -181,7 +181,7 @@ export class EventRenderingService { const newStart = swpEvent.start; const newEnd = swpEvent.end; - this.eventManager.updateEvent(eventId, { + await this.eventManager.updateEvent(eventId, { start: newStart, end: newEnd }); @@ -262,7 +262,7 @@ export class EventRenderingService { } private setupResizeEndListener(): void { - this.eventBus.on('resize:end', (event: Event) => { + 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 @@ -270,7 +270,7 @@ export class EventRenderingService { const newStart = swpEvent.start; const newEnd = swpEvent.end; - this.eventManager.updateEvent(eventId, { + await this.eventManager.updateEvent(eventId, { start: newStart, end: newEnd }); diff --git a/src/repositories/ApiEventRepository.ts b/src/repositories/ApiEventRepository.ts new file mode 100644 index 0000000..d38ba0e --- /dev/null +++ b/src/repositories/ApiEventRepository.ts @@ -0,0 +1,129 @@ +import { ICalendarEvent } from '../types/CalendarTypes'; + +/** + * ApiEventRepository + * Handles communication with backend API + * + * Used by SyncManager to send queued operations to the server + * NOT used directly by EventManager (which uses IndexedDBEventRepository) + * + * Future enhancements: + * - SignalR real-time updates + * - Conflict resolution + * - Batch operations + */ +export class ApiEventRepository { + private apiEndpoint: string; + + constructor(apiEndpoint: string) { + this.apiEndpoint = apiEndpoint; + } + + /** + * Send create operation to API + */ + async sendCreate(event: ICalendarEvent): Promise { + // TODO: Implement API call + // const response = await fetch(`${this.apiEndpoint}/events`, { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify(event) + // }); + // + // if (!response.ok) { + // throw new Error(`API create failed: ${response.statusText}`); + // } + // + // return await response.json(); + + throw new Error('ApiEventRepository.sendCreate not implemented yet'); + } + + /** + * Send update operation to API + */ + async sendUpdate(id: string, updates: Partial): Promise { + // TODO: Implement API call + // const response = await fetch(`${this.apiEndpoint}/events/${id}`, { + // method: 'PATCH', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify(updates) + // }); + // + // if (!response.ok) { + // throw new Error(`API update failed: ${response.statusText}`); + // } + // + // return await response.json(); + + throw new Error('ApiEventRepository.sendUpdate not implemented yet'); + } + + /** + * Send delete operation to API + */ + async sendDelete(id: string): Promise { + // TODO: Implement API call + // const response = await fetch(`${this.apiEndpoint}/events/${id}`, { + // method: 'DELETE' + // }); + // + // if (!response.ok) { + // throw new Error(`API delete failed: ${response.statusText}`); + // } + + throw new Error('ApiEventRepository.sendDelete not implemented yet'); + } + + /** + * Fetch all events from API + */ + async fetchAll(): Promise { + // TODO: Implement API call + // const response = await fetch(`${this.apiEndpoint}/events`); + // + // if (!response.ok) { + // throw new Error(`API fetch failed: ${response.statusText}`); + // } + // + // return await response.json(); + + throw new Error('ApiEventRepository.fetchAll not implemented yet'); + } + + // ======================================== + // Future: SignalR Integration + // ======================================== + + /** + * Initialize SignalR connection + * Placeholder for future implementation + */ + async initializeSignalR(): Promise { + // TODO: Setup SignalR connection + // - Connect to hub + // - Register event handlers + // - Handle reconnection + // + // Example: + // const connection = new signalR.HubConnectionBuilder() + // .withUrl(`${this.apiEndpoint}/hubs/calendar`) + // .build(); + // + // connection.on('EventCreated', (event: ICalendarEvent) => { + // // Handle remote create + // }); + // + // connection.on('EventUpdated', (event: ICalendarEvent) => { + // // Handle remote update + // }); + // + // connection.on('EventDeleted', (eventId: string) => { + // // Handle remote delete + // }); + // + // await connection.start(); + + throw new Error('SignalR not implemented yet'); + } +} diff --git a/src/repositories/IEventRepository.ts b/src/repositories/IEventRepository.ts index d73949f..da8e131 100644 --- a/src/repositories/IEventRepository.ts +++ b/src/repositories/IEventRepository.ts @@ -1,13 +1,21 @@ import { ICalendarEvent } from '../types/CalendarTypes'; /** - * IEventRepository - Interface for event data loading + * 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 mock data, REST API, GraphQL, or other data sources. + * between IndexedDB, REST API, GraphQL, or other data sources. * * Implementations: - * - MockEventRepository: Loads from local JSON file + * - IndexedDBEventRepository: Local storage with offline support + * - MockEventRepository: (Legacy) Loads from local JSON file * - ApiEventRepository: (Future) Loads from backend API */ export interface IEventRepository { @@ -17,4 +25,32 @@ export interface IEventRepository { * @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..507de58 --- /dev/null +++ b/src/repositories/IndexedDBEventRepository.ts @@ -0,0 +1,145 @@ +import { ICalendarEvent } from '../types/CalendarTypes'; +import { IEventRepository, UpdateSource } from './IEventRepository'; +import { IndexedDBService } from '../storage/IndexedDBService'; +import { OperationQueue } from '../storage/OperationQueue'; + +/** + * IndexedDBEventRepository + * Offline-first repository using IndexedDB as single source of truth + * + * All CRUD operations: + * - Save to IndexedDB immediately (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 queue: OperationQueue; + + constructor(indexedDB: IndexedDBService, queue: OperationQueue) { + this.indexedDB = indexedDB; + this.queue = queue; + } + + /** + * Load all events from IndexedDB + */ + async loadEvents(): Promise { + return await this.indexedDB.getAllEvents(); + } + + /** + * 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 + await this.indexedDB.saveEvent(newEvent); + + // If local change, add to sync queue + if (source === 'local') { + await this.queue.enqueue({ + type: 'create', + eventId: id, + 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 + const existingEvent = await this.indexedDB.getEvent(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 + await this.indexedDB.saveEvent(updatedEvent); + + // If local change, add to sync queue + if (source === 'local') { + await this.queue.enqueue({ + type: 'update', + eventId: id, + 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 + const existingEvent = await this.indexedDB.getEvent(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', + eventId: id, + data: {}, // No data needed for delete + timestamp: Date.now(), + retryCount: 0 + }); + } + + // Delete from IndexedDB + await this.indexedDB.deleteEvent(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/MockEventRepository.ts b/src/repositories/MockEventRepository.ts index 662f661..8cc17ce 100644 --- a/src/repositories/MockEventRepository.ts +++ b/src/repositories/MockEventRepository.ts @@ -1,5 +1,5 @@ import { ICalendarEvent } from '../types/CalendarTypes'; -import { IEventRepository } from './IEventRepository'; +import { IEventRepository, UpdateSource } from './IEventRepository'; interface RawEventData { id: string; @@ -13,12 +13,15 @@ interface RawEventData { } /** - * 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 before backend API is available. + * DEPRECATED: Use IndexedDBEventRepository for offline-first functionality. * * Data Source: data/mock-events.json + * + * NOTE: Create/Update/Delete operations are not supported - throws errors. + * This is intentional to encourage migration to IndexedDBEventRepository. */ export class MockEventRepository implements IEventRepository { private readonly dataUrl = 'data/mock-events.json'; @@ -40,6 +43,30 @@ export class MockEventRepository implements IEventRepository { } } + /** + * NOT SUPPORTED - MockEventRepository is read-only + * Use IndexedDBEventRepository instead + */ + 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 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 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 => ({ ...event, diff --git a/src/storage/IndexedDBService.ts b/src/storage/IndexedDBService.ts new file mode 100644 index 0000000..48ac931 --- /dev/null +++ b/src/storage/IndexedDBService.ts @@ -0,0 +1,401 @@ +import { ICalendarEvent } from '../types/CalendarTypes'; + +/** + * Operation for the sync queue + */ +export interface IQueueOperation { + id: string; + type: 'create' | 'update' | 'delete'; + eventId: string; + data: Partial | ICalendarEvent; + timestamp: number; + retryCount: number; +} + +/** + * IndexedDB Service for Calendar App + * Handles local storage of events and sync queue + */ +export class IndexedDBService { + private static readonly DB_NAME = 'CalendarDB'; + private static readonly DB_VERSION = 1; + private static readonly EVENTS_STORE = 'events'; + private static readonly QUEUE_STORE = 'operationQueue'; + private static readonly SYNC_STATE_STORE = 'syncState'; + + private db: IDBDatabase | null = null; + + /** + * 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; + resolve(); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create events store + if (!db.objectStoreNames.contains(IndexedDBService.EVENTS_STORE)) { + const eventsStore = db.createObjectStore(IndexedDBService.EVENTS_STORE, { keyPath: 'id' }); + eventsStore.createIndex('start', 'start', { unique: false }); + eventsStore.createIndex('end', 'end', { unique: false }); + eventsStore.createIndex('syncStatus', 'syncStatus', { unique: false }); + } + + // Create operation queue store + 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 + if (!db.objectStoreNames.contains(IndexedDBService.SYNC_STATE_STORE)) { + db.createObjectStore(IndexedDBService.SYNC_STATE_STORE, { keyPath: 'key' }); + } + }; + }); + } + + /** + * 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 + // ======================================== + + /** + * Get a single event by ID + */ + async getEvent(id: string): Promise { + const db = this.ensureDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readonly'); + const store = transaction.objectStore(IndexedDBService.EVENTS_STORE); + const request = store.get(id); + + request.onsuccess = () => { + const event = request.result as ICalendarEvent | undefined; + resolve(event ? this.deserializeEvent(event) : null); + }; + + request.onerror = () => { + reject(new Error(`Failed to get event ${id}: ${request.error}`)); + }; + }); + } + + /** + * Get all events + */ + async getAllEvents(): Promise { + const db = this.ensureDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readonly'); + const store = transaction.objectStore(IndexedDBService.EVENTS_STORE); + const request = store.getAll(); + + request.onsuccess = () => { + const events = request.result as ICalendarEvent[]; + resolve(events.map(e => this.deserializeEvent(e))); + }; + + request.onerror = () => { + reject(new Error(`Failed to get all events: ${request.error}`)); + }; + }); + } + + /** + * Save an event (create or update) + */ + async saveEvent(event: ICalendarEvent): Promise { + const db = this.ensureDB(); + const serialized = this.serializeEvent(event); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readwrite'); + const store = transaction.objectStore(IndexedDBService.EVENTS_STORE); + const request = store.put(serialized); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to save event ${event.id}: ${request.error}`)); + }; + }); + } + + /** + * Delete an event + */ + async deleteEvent(id: string): Promise { + const db = this.ensureDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([IndexedDBService.EVENTS_STORE], 'readwrite'); + const store = transaction.objectStore(IndexedDBService.EVENTS_STORE); + const request = store.delete(id); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to delete event ${id}: ${request.error}`)); + }; + }); + } + + // ======================================== + // Queue Operations + // ======================================== + + /** + * Add operation to queue + */ + async addToQueue(operation: Omit): Promise { + const db = this.ensureDB(); + const queueItem: IQueueOperation = { + ...operation, + id: `${operation.type}-${operation.eventId}-${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}`)); + }; + }); + } + + // ======================================== + // Serialization Helpers + // ======================================== + + /** + * Serialize event for IndexedDB storage (convert Dates to ISO strings) + */ + private serializeEvent(event: ICalendarEvent): any { + 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 (convert ISO strings to Dates) + */ + private deserializeEvent(event: any): ICalendarEvent { + return { + ...event, + start: typeof event.start === 'string' ? new Date(event.start) : event.start, + end: typeof event.end === 'string' ? new Date(event.end) : event.end + }; + } + + /** + * 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}`)); + }; + }); + } + + /** + * Seed IndexedDB with mock data if empty + */ + async seedIfEmpty(mockDataUrl: string = 'data/mock-events.json'): Promise { + try { + const existingEvents = await this.getAllEvents(); + + if (existingEvents.length > 0) { + console.log(`IndexedDB already has ${existingEvents.length} events - skipping seed`); + return; + } + + console.log('IndexedDB is empty - seeding with mock data'); + + // Check if online to fetch mock data + if (!navigator.onLine) { + console.warn('Offline and IndexedDB empty - starting with no events'); + return; + } + + // Fetch mock events + const response = await fetch(mockDataUrl); + if (!response.ok) { + throw new Error(`Failed to fetch mock events: ${response.statusText}`); + } + + const mockEvents = await response.json(); + + // Convert and save to IndexedDB + for (const event of mockEvents) { + const calendarEvent = { + ...event, + start: new Date(event.start), + end: new Date(event.end), + allDay: event.allDay || false, + syncStatus: 'synced' as const + }; + await this.saveEvent(calendarEvent); + } + + console.log(`Seeded IndexedDB with ${mockEvents.length} mock events`); + } catch (error) { + console.error('Failed to seed IndexedDB:', error); + // Don't throw - allow app to start with empty calendar + } + } +} diff --git a/src/storage/OperationQueue.ts b/src/storage/OperationQueue.ts new file mode 100644 index 0000000..3c0f360 --- /dev/null +++ b/src/storage/OperationQueue.ts @@ -0,0 +1,111 @@ +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 event ID + */ + async getOperationsForEvent(eventId: string): Promise { + const queue = await this.getAll(); + return queue.filter(op => op.eventId === eventId); + } + + /** + * Remove all operations for a specific event ID + */ + async removeOperationsForEvent(eventId: string): Promise { + const operations = await this.getOperationsForEvent(eventId); + for (const op of operations) { + await this.remove(op.id); + } + } + + /** + * 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/workers/SyncManager.ts b/src/workers/SyncManager.ts new file mode 100644 index 0000000..b311b44 --- /dev/null +++ b/src/workers/SyncManager.ts @@ -0,0 +1,276 @@ +import { IEventBus } from '../types/CalendarTypes'; +import { CoreEvents } from '../constants/CoreEvents'; +import { OperationQueue } from '../storage/OperationQueue'; +import { IQueueOperation } from '../storage/IndexedDBService'; +import { IndexedDBService } from '../storage/IndexedDBService'; +import { ApiEventRepository } from '../repositories/ApiEventRepository'; + +/** + * SyncManager - Background sync worker + * Processes operation queue and syncs with API when online + * + * Features: + * - Monitors online/offline status + * - 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 queue: OperationQueue; + private indexedDB: IndexedDBService; + private apiRepository: ApiEventRepository; + + private isOnline: boolean = navigator.onLine; + private isSyncing: boolean = false; + private syncInterval: number = 5000; // 5 seconds + private maxRetries: number = 5; + private intervalId: number | null = null; + + constructor( + eventBus: IEventBus, + queue: OperationQueue, + indexedDB: IndexedDBService, + apiRepository: ApiEventRepository + ) { + this.eventBus = eventBus; + this.queue = queue; + this.indexedDB = indexedDB; + this.apiRepository = apiRepository; + + this.setupNetworkListeners(); + } + + /** + * Setup online/offline event listeners + */ + private setupNetworkListeners(): void { + window.addEventListener('online', () => { + this.isOnline = true; + this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, { + isOnline: true + }); + console.log('SyncManager: Network online - starting sync'); + this.startSync(); + }); + + window.addEventListener('offline', () => { + this.isOnline = false; + this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, { + isOnline: false + }); + console.log('SyncManager: Network offline - pausing sync'); + this.stopSync(); + }); + } + + /** + * Start background sync worker + */ + public startSync(): void { + if (this.intervalId) { + return; // Already running + } + + console.log('SyncManager: Starting background sync'); + + // Process immediately + this.processQueue(); + + // Then poll every syncInterval + this.intervalId = window.setInterval(() => { + this.processQueue(); + }, this.syncInterval); + } + + /** + * Stop background sync worker + */ + public stopSync(): void { + if (this.intervalId) { + window.clearInterval(this.intervalId); + this.intervalId = null; + console.log('SyncManager: Stopped background sync'); + } + } + + /** + * Process operation queue + * Sends pending operations to API + */ + private async processQueue(): Promise { + // Don't sync if offline + if (!this.isOnline) { + return; + } + + // Don't start new sync if already syncing + if (this.isSyncing) { + return; + } + + // Check if queue is empty + if (await this.queue.isEmpty()) { + return; + } + + this.isSyncing = true; + + try { + const operations = await this.queue.getAll(); + + this.eventBus.emit(CoreEvents.SYNC_STARTED, { + operationCount: operations.length + }); + + // Process operations one by one (FIFO) + for (const operation of operations) { + await this.processOperation(operation); + } + + this.eventBus.emit(CoreEvents.SYNC_COMPLETED, { + operationCount: operations.length + }); + + } catch (error) { + console.error('SyncManager: Queue processing error:', error); + this.eventBus.emit(CoreEvents.SYNC_FAILED, { + error: error instanceof Error ? error.message : 'Unknown error' + }); + } finally { + this.isSyncing = false; + } + } + + /** + * Process a single operation + */ + private async processOperation(operation: IQueueOperation): Promise { + // Check if max retries exceeded + if (operation.retryCount >= this.maxRetries) { + console.error(`SyncManager: Max retries exceeded for operation ${operation.id}`, operation); + await this.queue.remove(operation.id); + await this.markEventAsError(operation.eventId); + return; + } + + try { + // Send to API based on operation type + switch (operation.type) { + case 'create': + await this.apiRepository.sendCreate(operation.data as any); + break; + + case 'update': + await this.apiRepository.sendUpdate(operation.eventId, operation.data); + break; + + case 'delete': + await this.apiRepository.sendDelete(operation.eventId); + 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.markEventAsSynced(operation.eventId); + + console.log(`SyncManager: Successfully synced operation ${operation.id}`); + + } catch (error) { + console.error(`SyncManager: Failed to sync operation ${operation.id}:`, error); + + // Increment retry count + await this.queue.incrementRetryCount(operation.id); + + // Calculate backoff delay + const backoffDelay = this.calculateBackoff(operation.retryCount + 1); + + this.eventBus.emit(CoreEvents.SYNC_RETRY, { + operationId: operation.id, + retryCount: operation.retryCount + 1, + nextRetryIn: backoffDelay + }); + } + } + + /** + * Mark event as synced in IndexedDB + */ + private async markEventAsSynced(eventId: string): Promise { + try { + const event = await this.indexedDB.getEvent(eventId); + if (event) { + event.syncStatus = 'synced'; + await this.indexedDB.saveEvent(event); + } + } catch (error) { + console.error(`SyncManager: Failed to mark event ${eventId} as synced:`, error); + } + } + + /** + * Mark event as error in IndexedDB + */ + private async markEventAsError(eventId: string): Promise { + try { + const event = await this.indexedDB.getEvent(eventId); + if (event) { + event.syncStatus = 'error'; + await this.indexedDB.saveEvent(event); + } + } catch (error) { + console.error(`SyncManager: Failed to mark event ${eventId} as error:`, error); + } + } + + /** + * Calculate exponential backoff delay + * @param retryCount Current retry count + * @returns Delay in milliseconds + */ + private calculateBackoff(retryCount: number): number { + // Exponential backoff: 2^retryCount * 1000ms + // Retry 1: 2s, Retry 2: 4s, Retry 3: 8s, Retry 4: 16s, Retry 5: 32s + const baseDelay = 1000; + const exponentialDelay = Math.pow(2, retryCount) * baseDelay; + const maxDelay = 60000; // Max 1 minute + return Math.min(exponentialDelay, maxDelay); + } + + /** + * Manually trigger sync (for testing or manual sync button) + */ + public async triggerManualSync(): Promise { + console.log('SyncManager: Manual sync triggered'); + await this.processQueue(); + } + + /** + * Get current sync status + */ + public getSyncStatus(): { + isOnline: boolean; + isSyncing: boolean; + isRunning: boolean; + } { + return { + isOnline: this.isOnline, + isSyncing: this.isSyncing, + isRunning: this.intervalId !== null + }; + } + + /** + * Cleanup - stop sync and remove listeners + */ + public destroy(): void { + this.stopSync(); + // Note: We don't remove window event listeners as they're global + } +} diff --git a/test/integrationtesting/README.md b/test/integrationtesting/README.md new file mode 100644 index 0000000..03d0552 --- /dev/null +++ b/test/integrationtesting/README.md @@ -0,0 +1,130 @@ +# Integration Testing + +Denne folder indeholder integration test pages til offline-first calendar funktionalitet. + +## Test Filer + +### Test Pages +- **`offline-test.html`** - Interaktiv CRUD testing playground +- **`sync-visualization.html`** - Live monitoring af sync queue og IndexedDB + +### Data & Scripts +- **`test-events.json`** - 10 test events til seeding af IndexedDB +- **`test-init.js`** - Standalone initialisering af IndexedDB, queue, event manager og sync manager + +## Sådan Bruges Test Siderne + +### 1. Start Development Server +Test siderne skal køres via en web server (ikke file://) for at kunne loade test-events.json: + +```bash +# Fra root af projektet +npm run dev +# eller +npx http-server -p 8080 +``` + +### 2. Åbn Test Siderne +Naviger til: +- `http://localhost:8080/test/integrationtesting/offline-test.html` +- `http://localhost:8080/test/integrationtesting/sync-visualization.html` + +### 3. Test Offline Mode +1. Åbn DevTools (F12) +2. Gå til Network tab +3. Aktiver "Offline" mode +4. Test CRUD operationer - de skulle gemmes lokalt i IndexedDB +5. Deaktiver "Offline" mode +6. Observer sync queue blive processeret + +## Test Pages Detaljer + +### offline-test.html +Interaktiv testing af: +- ✅ Create timed events +- ✅ Create all-day events +- ✅ Update event title +- ✅ Toggle all-day status +- ✅ Delete events +- ✅ List all events +- ✅ Show operation queue +- ✅ Trigger manual sync +- ✅ Clear all data + +### sync-visualization.html +Live monitoring af: +- 📊 IndexedDB events med sync status badges +- 📊 Operation queue med retry counts +- 📊 Statistics (synced/pending/error counts) +- 📊 Real-time sync log +- 🔄 Auto-refresh hver 2 sekunder +- ⏱️ Last sync timestamp i status bar + +## Teknisk Implementation + +### test-init.js +Standalone JavaScript fil der initialiserer: + +```javascript +window.calendarDebug = { + indexedDB, // TestIndexedDBService instance + queue, // TestOperationQueue instance + eventManager, // TestEventManager instance + syncManager // TestSyncManager instance +} +``` + +**Forskel fra main app:** +- Ingen NovaDI dependency injection +- Ingen DOM afhængigheder (swp-calendar-container etc.) +- Simplified event manager uden event bus +- Mock sync manager med simuleret API logic (80% success, 20% failure rate) +- Auto-seed fra test-events.json hvis IndexedDB er tom +- Pending events fra seed får automatisk queue operations + +**TestSyncManager Behavior:** +- ✅ Tjekker `navigator.onLine` før sync (respekterer offline mode) +- ✅ Simulerer netværk delay (100-500ms per operation) +- ✅ 80% chance for success → fjerner fra queue, markerer som 'synced' +- ✅ 20% chance for failure → incrementerer retryCount +- ✅ Efter 5 fejl → markerer event som 'error' og fjerner fra queue +- ✅ Viser detaljeret logging i console +- ✅ Network listeners opdaterer online/offline status automatisk + +### Data Flow +``` +User Action → EventManager + → IndexedDB (saveEvent) + → OperationQueue (enqueue) + → SyncManager (background sync når online) +``` + +### Database Isolation +Test-siderne bruger **`CalendarDB_Test`** som database navn, mens main calendar app bruger **`CalendarDB`**. Dette sikrer at test data IKKE blandes med produktions data. De to systemer er helt isolerede fra hinanden. + +## Troubleshooting + +### "Calendar system failed to initialize" +- Kontroller at du kører via web server (ikke file://) +- Check browser console for fejl +- Verificer at test-init.js loades korrekt + +### "Could not load test-events.json" +- Normal warning hvis IndexedDB allerede har data +- For at reset: Open DevTools → Application → IndexedDB → Delete CalendarDB + +### Events forsvinder efter refresh +- Dette skulle IKKE ske - IndexedDB persisterer data +- Hvis det sker: Check console for IndexedDB errors +- Verificer at browser ikke er i private/incognito mode + +### Test events vises i prod calendar +- Test-siderne bruger `CalendarDB_Test` database +- Main calendar bruger `CalendarDB` database +- Hvis de blandes: Clear begge databases i DevTools → Application → IndexedDB + +## Development Notes + +Test siderne bruger IKKE den compiled calendar.js bundle. De er helt standalone og initialiserer deres egne services direkte. Dette gør dem hurtigere at udvikle på og lettere at debugge. + +Når API backend implementeres skal `TestSyncManager` opdateres til at lave rigtige HTTP calls i stedet for mock sync. diff --git a/test/integrationtesting/offline-test.html b/test/integrationtesting/offline-test.html new file mode 100644 index 0000000..b97f137 --- /dev/null +++ b/test/integrationtesting/offline-test.html @@ -0,0 +1,974 @@ + + + + + + OFFLINE MODE TESTING | Calendar System + + + +
+
+

OFFLINE MODE TESTING

+

// Interactive testing playground for offline-first calendar functionality

+
+ [⏳] INITIALIZING CALENDAR SYSTEM... +
+
+ [●] NETWORK: ONLINE +
+ +
+

TESTING PROTOCOL

+
    +
  1. Perform CRUD operations below (create, update, delete events)
  2. +
  3. Open DevTools → Network tab → Check "Offline" to simulate offline mode
  4. +
  5. Continue performing operations → they will be queued
  6. +
  7. Open Sync Visualization to monitor the queue
  8. +
  9. Uncheck "Offline" to go back online → operations will sync automatically
  10. +
  11. Press F5 while offline → verify data persists from IndexedDB
  12. +
+
+
+ + +
+
CREATE OPERATIONS
+
+
+
Create Timed Event
+
// Creates a new timed event in the calendar
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+
Create All-Day Event
+
// Creates a new all-day event
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ + +
+
UPDATE OPERATIONS
+
+
+
Update Event Title
+
// Update the title of an existing event
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+
Toggle All-Day Status
+
// Convert between timed and all-day event
+ +
+ + +
+ + +
+
+
+
+ + +
+
DELETE OPERATIONS
+
+
+
Delete by ID
+
// Permanently delete an event
+ +
+ + +
+ + +
+
+
+
+ + +
+
UTILITY OPERATIONS
+ +
+ + + + +
+ +
+
+ + +
+
+ EVENT PREVIEW + +
+
+
+
+ + + + + + + diff --git a/stacking-visualization-new.html b/test/integrationtesting/stacking-visualization-new.html similarity index 100% rename from stacking-visualization-new.html rename to test/integrationtesting/stacking-visualization-new.html diff --git a/stacking-visualization.html b/test/integrationtesting/stacking-visualization.html similarity index 100% rename from stacking-visualization.html rename to test/integrationtesting/stacking-visualization.html diff --git a/test/integrationtesting/sync-visualization.html b/test/integrationtesting/sync-visualization.html new file mode 100644 index 0000000..336a60a --- /dev/null +++ b/test/integrationtesting/sync-visualization.html @@ -0,0 +1,854 @@ + + + + + + SYNC QUEUE VISUALIZATION | Calendar System + + + +
+

SYNC QUEUE VISUALIZATION

+

// Live monitoring of offline-first calendar sync operations

+ +
+ [⏳] INITIALIZING CALENDAR SYSTEM... +
+ +
+
+ NETWORK: + ONLINE +
+
+ SYNC: + IDLE +
+
+ AUTO-REFRESH: + +
+
+ LAST SYNC: + NEVER +
+
+ +
+ + + + + +
+
+ +
+ +
+
+ INDEXEDDB EVENTS + 0 +
+
+
+ + +
+
+ OPERATION QUEUE + 0 +
+
+
+ + +
+
+ STATISTICS +
+
+
+
0
+
Synced
+
+
+
0
+
Pending
+
+
+
0
+
Errors
+
+
+
0
+
In Queue
+
+
+
+ + +
+
+ SYNC LOG + +
+
+
+
+ + + + + + + diff --git a/test/integrationtesting/test-events.json b/test/integrationtesting/test-events.json new file mode 100644 index 0000000..0feeee0 --- /dev/null +++ b/test/integrationtesting/test-events.json @@ -0,0 +1,132 @@ +[ + { + "id": "test-1", + "title": "Morning Standup", + "start": "2025-11-04T08:00:00Z", + "end": "2025-11-04T08:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ff5722" + } + }, + { + "id": "test-2", + "title": "Development Sprint", + "start": "2025-11-04T09:00:00Z", + "end": "2025-11-04T12:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#2196f3" + } + }, + { + "id": "test-3", + "title": "Lunch Break", + "start": "2025-11-04T12:00:00Z", + "end": "2025-11-04T13:00:00Z", + "type": "break", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4caf50" + } + }, + { + "id": "test-4", + "title": "Client Meeting", + "start": "2025-11-04T14:00:00Z", + "end": "2025-11-04T15:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#673ab7" + } + }, + { + "id": "test-5", + "title": "Code Review Session", + "start": "2025-11-04T16:00:00Z", + "end": "2025-11-04T17:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff9800" + } + }, + { + "id": "test-6", + "title": "Public Holiday", + "start": "2025-11-05T00:00:00Z", + "end": "2025-11-05T23:59:59Z", + "type": "holiday", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#f44336" + } + }, + { + "id": "test-7", + "title": "Team Workshop", + "start": "2025-11-06T09:00:00Z", + "end": "2025-11-06T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 150, + "color": "#9c27b0" + } + }, + { + "id": "test-8", + "title": "Birthday Celebration", + "start": "2025-11-07T00:00:00Z", + "end": "2025-11-07T23:59:59Z", + "type": "personal", + "allDay": true, + "syncStatus": "synced", + "metadata": { + "duration": 1440, + "color": "#e91e63" + } + }, + { + "id": "test-9", + "title": "Sprint Retrospective", + "start": "2025-11-07T13:00:00Z", + "end": "2025-11-07T14:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "pending", + "metadata": { + "duration": 90, + "color": "#3f51b5" + } + }, + { + "id": "test-10", + "title": "Documentation Update", + "start": "2025-11-08T10:00:00Z", + "end": "2025-11-08T12:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "pending", + "metadata": { + "duration": 120, + "color": "#009688" + } + } +] diff --git a/test/integrationtesting/test-init.js b/test/integrationtesting/test-init.js new file mode 100644 index 0000000..be54add --- /dev/null +++ b/test/integrationtesting/test-init.js @@ -0,0 +1,452 @@ +/** + * Test Initialization Script + * Standalone initialization for test pages without requiring full calendar DOM + */ + +// IndexedDB Service (simplified standalone version) +class TestIndexedDBService { + constructor() { + this.DB_NAME = 'CalendarDB_Test'; // Separate test database + this.DB_VERSION = 1; + this.EVENTS_STORE = 'events'; + this.QUEUE_STORE = 'operationQueue'; + this.SYNC_STATE_STORE = 'syncState'; + this.db = null; + } + + async initialize() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.DB_NAME, this.DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + this.db = request.result; + resolve(); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Create events store + if (!db.objectStoreNames.contains(this.EVENTS_STORE)) { + const eventStore = db.createObjectStore(this.EVENTS_STORE, { keyPath: 'id' }); + eventStore.createIndex('start', 'start', { unique: false }); + eventStore.createIndex('end', 'end', { unique: false }); + eventStore.createIndex('syncStatus', 'syncStatus', { unique: false }); + } + + // Create operation queue store + if (!db.objectStoreNames.contains(this.QUEUE_STORE)) { + const queueStore = db.createObjectStore(this.QUEUE_STORE, { keyPath: 'id', autoIncrement: true }); + queueStore.createIndex('timestamp', 'timestamp', { unique: false }); + queueStore.createIndex('eventId', 'eventId', { unique: false }); + } + + // Create sync state store + if (!db.objectStoreNames.contains(this.SYNC_STATE_STORE)) { + db.createObjectStore(this.SYNC_STATE_STORE, { keyPath: 'key' }); + } + }; + }); + } + + async getAllEvents() { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.EVENTS_STORE], 'readonly'); + const store = transaction.objectStore(this.EVENTS_STORE); + const request = store.getAll(); + + request.onsuccess = () => { + const events = request.result.map(event => ({ + ...event, + start: new Date(event.start), + end: new Date(event.end) + })); + resolve(events); + }; + request.onerror = () => reject(request.error); + }); + } + + async getEvent(id) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.EVENTS_STORE], 'readonly'); + const store = transaction.objectStore(this.EVENTS_STORE); + const request = store.get(id); + + request.onsuccess = () => { + const event = request.result; + if (event) { + event.start = new Date(event.start); + event.end = new Date(event.end); + } + resolve(event || null); + }; + request.onerror = () => reject(request.error); + }); + } + + async saveEvent(event) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.EVENTS_STORE], 'readwrite'); + const store = transaction.objectStore(this.EVENTS_STORE); + const eventToSave = { + ...event, + start: event.start instanceof Date ? event.start.toISOString() : event.start, + end: event.end instanceof Date ? event.end.toISOString() : event.end + }; + const request = store.put(eventToSave); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async deleteEvent(id) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.EVENTS_STORE], 'readwrite'); + const store = transaction.objectStore(this.EVENTS_STORE); + const request = store.delete(id); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async addToQueue(operation) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.QUEUE_STORE], 'readwrite'); + const store = transaction.objectStore(this.QUEUE_STORE); + const request = store.add(operation); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async getQueue() { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.QUEUE_STORE], 'readonly'); + const store = transaction.objectStore(this.QUEUE_STORE); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async removeFromQueue(id) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.QUEUE_STORE], 'readwrite'); + const store = transaction.objectStore(this.QUEUE_STORE); + const request = store.delete(id); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async clearQueue() { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([this.QUEUE_STORE], 'readwrite'); + const store = transaction.objectStore(this.QUEUE_STORE); + const request = store.clear(); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + close() { + if (this.db) { + this.db.close(); + } + } +} + +// Operation Queue (simplified standalone version) +class TestOperationQueue { + constructor(indexedDB) { + this.indexedDB = indexedDB; + } + + async enqueue(operation) { + await this.indexedDB.addToQueue(operation); + } + + async getAll() { + return await this.indexedDB.getQueue(); + } + + async remove(id) { + await this.indexedDB.removeFromQueue(id); + } + + async clear() { + await this.indexedDB.clearQueue(); + } + + async incrementRetryCount(operationId) { + const queue = await this.getAll(); + const operation = queue.find(op => op.id === operationId); + if (operation) { + operation.retryCount = (operation.retryCount || 0) + 1; + await this.indexedDB.removeFromQueue(operationId); + await this.indexedDB.addToQueue(operation); + } + } +} + +// Simple EventManager for tests +class TestEventManager { + constructor(indexedDB, queue) { + this.indexedDB = indexedDB; + this.queue = queue; + } + + async getAllEvents() { + return await this.indexedDB.getAllEvents(); + } + + async getEvent(id) { + return await this.indexedDB.getEvent(id); + } + + async addEvent(eventData) { + const id = eventData.id || `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const syncStatus = eventData.syncStatus || 'pending'; + + const newEvent = { + ...eventData, + id, + syncStatus + }; + + await this.indexedDB.saveEvent(newEvent); + + if (syncStatus === 'pending') { + await this.queue.enqueue({ + type: 'create', + eventId: id, + data: newEvent, + timestamp: Date.now(), + retryCount: 0 + }); + } + + return newEvent; + } + + async updateEvent(id, updates) { + const event = await this.indexedDB.getEvent(id); + if (!event) return null; + + const updatedEvent = { ...event, ...updates, syncStatus: 'pending' }; + await this.indexedDB.saveEvent(updatedEvent); + + await this.queue.enqueue({ + type: 'update', + eventId: id, + data: updates, + timestamp: Date.now(), + retryCount: 0 + }); + + return updatedEvent; + } + + async deleteEvent(id) { + await this.indexedDB.deleteEvent(id); + await this.queue.enqueue({ + type: 'delete', + eventId: id, + data: null, + timestamp: Date.now(), + retryCount: 0 + }); + } +} + +// Minimal SyncManager for tests with mock API simulation +class TestSyncManager { + constructor(queue, indexedDB) { + this.queue = queue; + this.indexedDB = indexedDB; + this.isOnline = navigator.onLine; + this.maxRetries = 5; + this.setupNetworkListeners(); + } + + setupNetworkListeners() { + window.addEventListener('online', () => { + this.isOnline = true; + console.log('[TestSyncManager] Network online'); + }); + + window.addEventListener('offline', () => { + this.isOnline = false; + console.log('[TestSyncManager] Network offline'); + }); + } + + async triggerManualSync() { + console.log('[TestSyncManager] Manual sync triggered'); + + // Check if online before syncing + if (!this.isOnline) { + console.warn('[TestSyncManager] ⚠️ Cannot sync - offline mode'); + throw new Error('Cannot sync while offline'); + } + + const queueItems = await this.queue.getAll(); + console.log(`[TestSyncManager] Queue has ${queueItems.length} items`); + + if (queueItems.length === 0) { + console.log('[TestSyncManager] Queue is empty - nothing to sync'); + return []; + } + + // Process each operation + for (const operation of queueItems) { + await this.processOperation(operation); + } + + return queueItems; + } + + async processOperation(operation) { + console.log(`[TestSyncManager] Processing operation ${operation.id} (retry: ${operation.retryCount})`); + + // Check if max retries exceeded + if (operation.retryCount >= this.maxRetries) { + console.error(`[TestSyncManager] Max retries (${this.maxRetries}) exceeded for operation ${operation.id}`); + await this.queue.remove(operation.id); + await this.markEventAsError(operation.eventId); + return; + } + + // Simulate API call with delay + await this.simulateApiCall(); + + // Simulate success (80%) or failure (20%) + const success = Math.random() > 0.2; + + if (success) { + console.log(`[TestSyncManager] ✓ Operation ${operation.id} synced successfully`); + await this.queue.remove(operation.id); + await this.markEventAsSynced(operation.eventId); + } else { + console.warn(`[TestSyncManager] ✗ Operation ${operation.id} failed - will retry`); + await this.queue.incrementRetryCount(operation.id); + } + } + + async simulateApiCall() { + // Simulate network delay (100-500ms) + const delay = Math.floor(Math.random() * 400) + 100; + return new Promise(resolve => setTimeout(resolve, delay)); + } + + async markEventAsSynced(eventId) { + try { + const event = await this.indexedDB.getEvent(eventId); + if (event) { + event.syncStatus = 'synced'; + await this.indexedDB.saveEvent(event); + console.log(`[TestSyncManager] Event ${eventId} marked as synced`); + } + } catch (error) { + console.error(`[TestSyncManager] Failed to mark event ${eventId} as synced:`, error); + } + } + + async markEventAsError(eventId) { + try { + const event = await this.indexedDB.getEvent(eventId); + if (event) { + event.syncStatus = 'error'; + await this.indexedDB.saveEvent(event); + console.log(`[TestSyncManager] Event ${eventId} marked as error`); + } + } catch (error) { + console.error(`[TestSyncManager] Failed to mark event ${eventId} as error:`, error); + } + } +} + +// Initialize test environment +async function initializeTestEnvironment() { + console.log('[Test Init] Initializing test environment...'); + + const indexedDB = new TestIndexedDBService(); + await indexedDB.initialize(); + console.log('[Test Init] IndexedDB initialized'); + + const queue = new TestOperationQueue(indexedDB); + console.log('[Test Init] Operation queue created'); + + const eventManager = new TestEventManager(indexedDB, queue); + console.log('[Test Init] Event manager created'); + + const syncManager = new TestSyncManager(queue, indexedDB); + console.log('[Test Init] Sync manager created'); + + // Seed with test data if empty + const existingEvents = await indexedDB.getAllEvents(); + if (existingEvents.length === 0) { + console.log('[Test Init] Seeding with test data...'); + try { + const response = await fetch('test-events.json'); + const testEvents = await response.json(); + for (const event of testEvents) { + const savedEvent = { + ...event, + start: new Date(event.start), + end: new Date(event.end) + }; + await indexedDB.saveEvent(savedEvent); + + // If event is pending, also add to queue + if (event.syncStatus === 'pending') { + await queue.enqueue({ + type: 'create', + eventId: event.id, + data: savedEvent, + timestamp: Date.now(), + retryCount: 0 + }); + console.log(`[Test Init] Added pending event ${event.id} to queue`); + } + } + console.log(`[Test Init] Seeded ${testEvents.length} test events`); + } catch (error) { + console.warn('[Test Init] Could not load test-events.json:', error); + } + } else { + console.log(`[Test Init] IndexedDB already has ${existingEvents.length} events`); + } + + // Expose to window + window.calendarDebug = { + indexedDB, + queue, + eventManager, + syncManager + }; + + console.log('[Test Init] Test environment ready'); + return { indexedDB, queue, eventManager, syncManager }; +} + +// Auto-initialize if script is loaded +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + initializeTestEnvironment().catch(error => { + console.error('[Test Init] Failed to initialize:', error); + }); + }); +} else { + initializeTestEnvironment().catch(error => { + console.error('[Test Init] Failed to initialize:', error); + }); +}