Implements offline-first calendar sync infrastructure

Adds IndexedDB and operation queue for robust offline synchronization
Introduces SyncManager to handle background data synchronization
Supports local event operations with automatic remote sync queuing

Enhances application reliability and user experience in low/no connectivity scenarios
This commit is contained in:
Janus C. H. Knudsen 2025-11-05 00:37:57 +01:00
parent 9c765b35ab
commit e7011526e3
20 changed files with 3822 additions and 57 deletions

View file

@ -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',

View file

@ -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<void> {
// 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<void> {
// Register configuration instance
builder.registerInstance(config).as<Configuration>();
// Register repositories
builder.registerType(MockEventRepository).as<IEventRepository>();
// Register IndexedDB and storage instances
builder.registerInstance(indexedDB).as<IndexedDBService>();
builder.registerInstance(queue).as<OperationQueue>();
builder.registerInstance(apiRepository).as<ApiEventRepository>();
// Register repository
builder.registerInstance(repository).as<IEventRepository>();
// Register renderers
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
@ -143,6 +171,13 @@ async function initializeCalendar(): Promise<void> {
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<void> {
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) {

View file

@ -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<IHeaderReadyEventPayload>).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<void> {
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();

View file

@ -211,7 +211,7 @@ export class CalendarManager {
/**
* Re-render events after grid structure changes
*/
private rerenderEvents(): void {
private async rerenderEvents(): Promise<void> {
// 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)

View file

@ -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<void> {
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<ICalendarEvent[]> {
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<ICalendarEvent | undefined> {
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<boolean> {
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<ICalendarEvent[]> {
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, 'id'>): 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<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
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>): ICalendarEvent | null {
const eventIndex = this.events.findIndex(event => event.id === id);
if (eventIndex === -1) return null;
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
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<boolean> {
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<void> {
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);
}
}
}

View file

@ -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<void> {
// 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<IDragEndEventPayload>).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<IResizeEndEventPayload>).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
});

View file

@ -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<ICalendarEvent> {
// 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<ICalendarEvent>): Promise<ICalendarEvent> {
// 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<void> {
// 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<ICalendarEvent[]> {
// 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<void> {
// 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');
}
}

View file

@ -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<ICalendarEvent[]>;
/**
* 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<ICalendarEvent, 'id'>, source?: UpdateSource): Promise<ICalendarEvent>;
/**
* 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<ICalendarEvent>, source?: UpdateSource): Promise<ICalendarEvent>;
/**
* 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<void>;
}

View file

@ -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<ICalendarEvent[]> {
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<ICalendarEvent, 'id'>, source: UpdateSource = 'local'): Promise<ICalendarEvent> {
// 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<ICalendarEvent>, source: UpdateSource = 'local'): Promise<ICalendarEvent> {
// 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<void> {
// 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}`;
}
}

View file

@ -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<ICalendarEvent, 'id'>, source?: UpdateSource): Promise<ICalendarEvent> {
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<ICalendarEvent>, source?: UpdateSource): Promise<ICalendarEvent> {
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<void> {
throw new Error('MockEventRepository does not support deleteEvent. Use IndexedDBEventRepository instead.');
}
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
return data.map((event): ICalendarEvent => ({
...event,

View file

@ -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> | 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<void> {
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<ICalendarEvent | null> {
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<ICalendarEvent[]> {
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<void> {
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<void> {
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<IQueueOperation, 'id'>): Promise<void> {
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<IQueueOperation[]> {
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<void> {
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<void> {
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<void> {
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<any | null> {
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<void> {
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<void> {
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
}
}
}

View file

@ -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<IQueueOperation, 'id'>): Promise<void> {
await this.indexedDB.addToQueue(operation);
}
/**
* Get the first operation from the queue (without removing it)
* Returns null if queue is empty
*/
async peek(): Promise<IQueueOperation | null> {
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<IQueueOperation[]> {
return await this.indexedDB.getQueue();
}
/**
* Remove a specific operation from the queue
*/
async remove(operationId: string): Promise<void> {
await this.indexedDB.removeFromQueue(operationId);
}
/**
* Remove the first operation from the queue and return it
* Returns null if queue is empty
*/
async dequeue(): Promise<IQueueOperation | null> {
const operation = await this.peek();
if (operation) {
await this.remove(operation.id);
}
return operation;
}
/**
* Clear all operations from the queue
*/
async clear(): Promise<void> {
await this.indexedDB.clearQueue();
}
/**
* Get the number of operations in the queue
*/
async size(): Promise<number> {
const queue = await this.getAll();
return queue.length;
}
/**
* Check if queue is empty
*/
async isEmpty(): Promise<boolean> {
const size = await this.size();
return size === 0;
}
/**
* Get operations for a specific event ID
*/
async getOperationsForEvent(eventId: string): Promise<IQueueOperation[]> {
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<void> {
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<void> {
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);
}
}
}

276
src/workers/SyncManager.ts Normal file
View file

@ -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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<void> {
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
}
}

View file

@ -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.

View file

@ -0,0 +1,974 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OFFLINE MODE TESTING | Calendar System</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f3f5;
--border-color: #dee2e6;
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--accent-primary: #0066cc;
--accent-secondary: #6610f2;
--success: #28a745;
--warning: #ffc107;
--error: #dc3545;
--info: #17a2b8;
}
body {
font-family: 'JetBrains Mono', 'Courier New', monospace;
background: var(--bg-primary);
color: var(--text-primary);
padding: 20px;
line-height: 1.6;
font-size: 13px;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: var(--bg-secondary);
padding: 24px;
border: 2px solid var(--border-color);
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
}
h1 {
color: var(--text-primary);
margin-bottom: 6px;
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
h1::before {
content: '▶ ';
color: var(--accent-primary);
}
.subtitle {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 20px;
font-weight: 400;
}
.network-status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
border: 2px solid;
font-weight: 600;
margin-bottom: 20px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.network-online {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.network-offline {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.test-section {
background: var(--bg-secondary);
padding: 20px;
border: 2px solid var(--border-color);
margin-bottom: 20px;
position: relative;
}
.test-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--accent-primary);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
text-transform: uppercase;
letter-spacing: 1px;
padding-bottom: 12px;
border-bottom: 2px solid var(--border-color);
}
.test-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.test-card {
border: 2px solid var(--border-color);
padding: 16px;
background: var(--bg-tertiary);
transition: all 0.2s;
}
.test-card:hover {
border-color: var(--accent-primary);
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15);
}
.card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-description {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 16px;
line-height: 1.6;
}
.form-group {
margin-bottom: 14px;
}
label {
display: block;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input, select, textarea {
width: 100%;
padding: 8px 10px;
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
transition: all 0.15s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
textarea {
resize: vertical;
min-height: 60px;
}
button {
width: 100%;
padding: 10px 14px;
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.15s ease;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
}
button:hover:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
background: var(--bg-tertiary);
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-create {
border-color: var(--success);
color: var(--success);
}
.btn-create:hover:not(:disabled) {
background: #d4edda;
}
.btn-update {
border-color: var(--info);
color: var(--info);
}
.btn-update:hover:not(:disabled) {
background: #d1ecf1;
}
.btn-delete {
border-color: var(--error);
color: var(--error);
}
.btn-delete:hover:not(:disabled) {
background: #f8d7da;
}
.btn-utility {
border-color: var(--accent-secondary);
color: var(--accent-secondary);
}
.btn-utility:hover:not(:disabled) {
background: #e7d8ff;
}
.result-box {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent-primary);
padding: 12px;
margin-top: 12px;
font-size: 11px;
max-height: 250px;
overflow-y: auto;
line-height: 1.6;
border: 2px solid var(--border-color);
border-left: 3px solid var(--accent-primary);
}
.result-success {
border-left-color: var(--success);
background: #d4edda;
color: var(--success);
}
.result-error {
border-left-color: var(--error);
background: #f8d7da;
color: var(--error);
}
.result-info {
border-left-color: var(--info);
background: #d1ecf1;
color: var(--info);
}
.instructions {
background: #fff3cd;
border-left: 3px solid var(--warning);
padding: 16px;
margin-bottom: 20px;
font-size: 11px;
border: 2px solid #ffeeba;
border-left: 3px solid var(--warning);
}
.instructions h3 {
color: #856404;
margin-bottom: 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.instructions ol {
margin-left: 20px;
color: var(--text-secondary);
line-height: 1.8;
}
.instructions a {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px solid transparent;
font-weight: 600;
}
.instructions a:hover {
border-bottom-color: var(--accent-primary);
}
.quick-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.quick-actions button {
flex: 1;
min-width: 200px;
}
code {
background: var(--bg-tertiary);
color: var(--accent-primary);
padding: 2px 6px;
border: 1px solid var(--border-color);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
}
.badge {
display: inline-block;
padding: 3px 10px;
border: 2px solid;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-success {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.badge-warning {
background: #fff3cd;
color: #856404;
border-color: var(--warning);
}
.badge-error {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.event-preview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 16px;
}
.event-preview-item {
padding: 12px;
border: 2px solid var(--border-color);
background: var(--bg-tertiary);
font-size: 11px;
transition: all 0.15s;
}
.event-preview-item:hover {
border-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 102, 204, 0.1);
}
.event-preview-title {
font-weight: 600;
margin-bottom: 6px;
color: var(--text-primary);
}
.event-preview-id {
font-size: 9px;
color: var(--text-muted);
margin-bottom: 6px;
word-break: break-all;
}
.event-preview-status {
font-size: 10px;
margin-top: 8px;
display: flex;
gap: 6px;
align-items: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>OFFLINE MODE TESTING</h1>
<p class="subtitle">// Interactive testing playground for offline-first calendar functionality</p>
<div id="initStatus" class="network-status" style="background: var(--warning); color: #000; display: block; margin-bottom: 8px;">
[⏳] INITIALIZING CALENDAR SYSTEM...
</div>
<div id="networkStatus" class="network-status network-online">
[●] NETWORK: ONLINE
</div>
<div class="instructions">
<h3>TESTING PROTOCOL</h3>
<ol>
<li>Perform CRUD operations below (create, update, delete events)</li>
<li>Open DevTools → Network tab → Check "Offline" to simulate offline mode</li>
<li>Continue performing operations → they will be queued</li>
<li>Open <a href="/test/integrationtesting/sync-visualization.html" target="_blank">Sync Visualization</a> to monitor the queue</li>
<li>Uncheck "Offline" to go back online → operations will sync automatically</li>
<li>Press F5 while offline → verify data persists from IndexedDB</li>
</ol>
</div>
</div>
<!-- Create Event -->
<div class="test-section">
<div class="section-title">CREATE OPERATIONS</div>
<div class="test-grid">
<div class="test-card">
<div class="card-title">Create Timed Event</div>
<div class="card-description">// Creates a new timed event in the calendar</div>
<div class="form-group">
<label>Title</label>
<input type="text" id="createTitle" placeholder="Team Meeting" value="Test Event">
</div>
<div class="form-group">
<label>Start Time</label>
<input type="datetime-local" id="createStart">
</div>
<div class="form-group">
<label>End Time</label>
<input type="datetime-local" id="createEnd">
</div>
<button class="btn-create" onclick="createTimedEvent()">CREATE TIMED EVENT</button>
<div id="createResult"></div>
</div>
<div class="test-card">
<div class="card-title">Create All-Day Event</div>
<div class="card-description">// Creates a new all-day event</div>
<div class="form-group">
<label>Title</label>
<input type="text" id="createAllDayTitle" placeholder="Holiday" value="All-Day Test">
</div>
<div class="form-group">
<label>Date</label>
<input type="date" id="createAllDayDate">
</div>
<button class="btn-create" onclick="createAllDayEvent()">CREATE ALL-DAY EVENT</button>
<div id="createAllDayResult"></div>
</div>
</div>
</div>
<!-- Update Event -->
<div class="test-section">
<div class="section-title">UPDATE OPERATIONS</div>
<div class="test-grid">
<div class="test-card">
<div class="card-title">Update Event Title</div>
<div class="card-description">// Update the title of an existing event</div>
<div class="form-group">
<label>Event ID</label>
<input type="text" id="updateEventId" placeholder="event_123456">
</div>
<div class="form-group">
<label>New Title</label>
<input type="text" id="updateTitle" placeholder="Updated Meeting">
</div>
<button class="btn-update" onclick="updateEventTitle()">UPDATE TITLE</button>
<div id="updateTitleResult"></div>
</div>
<div class="test-card">
<div class="card-title">Toggle All-Day Status</div>
<div class="card-description">// Convert between timed and all-day event</div>
<div class="form-group">
<label>Event ID</label>
<input type="text" id="toggleEventId" placeholder="event_123456">
</div>
<button class="btn-update" onclick="toggleAllDay()">TOGGLE ALL-DAY</button>
<div id="toggleResult"></div>
</div>
</div>
</div>
<!-- Delete Event -->
<div class="test-section">
<div class="section-title">DELETE OPERATIONS</div>
<div class="test-grid">
<div class="test-card">
<div class="card-title">Delete by ID</div>
<div class="card-description">// Permanently delete an event</div>
<div class="form-group">
<label>Event ID</label>
<input type="text" id="deleteEventId" placeholder="event_123456">
</div>
<button class="btn-delete" onclick="deleteEvent()">DELETE EVENT</button>
<div id="deleteResult"></div>
</div>
</div>
</div>
<!-- Utilities -->
<div class="test-section">
<div class="section-title">UTILITY OPERATIONS</div>
<div class="quick-actions">
<button class="btn-utility" onclick="listAllEvents()">LIST ALL EVENTS</button>
<button class="btn-utility" onclick="showQueue()">SHOW QUEUE</button>
<button class="btn-utility" onclick="triggerSync()">TRIGGER SYNC</button>
<button class="btn-delete" onclick="clearAllData()">CLEAR ALL DATA</button>
</div>
<div id="utilityResult"></div>
</div>
<!-- Event Preview -->
<div class="test-section">
<div class="section-title">
EVENT PREVIEW
<button class="btn-utility" onclick="refreshPreview()" style="width: auto; padding: 6px 12px; font-size: 10px; margin-left: auto;">REFRESH</button>
</div>
<div id="eventPreview" class="event-preview"></div>
</div>
</div>
<!-- Load Test Initialization Script -->
<script src="test-init.js"></script>
<script>
// Wait for calendar to initialize
let calendarReady = false;
let initCheckInterval;
function waitForCalendar() {
return new Promise((resolve) => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.display = 'none';
}
resolve();
return;
}
initCheckInterval = setInterval(() => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
clearInterval(initCheckInterval);
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.background = 'var(--success)';
initStatus.style.color = '#fff';
initStatus.textContent = '[✓] CALENDAR SYSTEM READY';
setTimeout(() => {
initStatus.style.display = 'none';
}, 1000);
}
resolve();
}
}, 100);
// Timeout after 10 seconds
setTimeout(() => {
if (!calendarReady) {
clearInterval(initCheckInterval);
console.error('Calendar failed to initialize within 10 seconds');
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.background = 'var(--error)';
initStatus.style.color = '#fff';
initStatus.textContent = '[✗] CALENDAR SYSTEM FAILED TO INITIALIZE';
}
document.getElementById('eventPreview').innerHTML = `
<div style="color: var(--error); padding: 20px; text-align: center;">
[ERROR] Calendar system failed to initialize<br>
<small>Check console for details</small>
</div>
`;
}
}, 10000);
});
}
// Initialize datetime inputs with current time
function initDateTimeInputs() {
const now = new Date();
const start = new Date(now.getTime() + 60 * 60 * 1000); // +1 hour
const end = new Date(start.getTime() + 60 * 60 * 1000); // +1 hour from start
document.getElementById('createStart').value = formatDateTimeLocal(start);
document.getElementById('createEnd').value = formatDateTimeLocal(end);
document.getElementById('createAllDayDate').value = formatDateLocal(now);
}
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function formatDateLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Network status monitoring
function updateNetworkStatus() {
const statusDiv = document.getElementById('networkStatus');
const isOnline = navigator.onLine;
statusDiv.textContent = isOnline ? '[●] NETWORK: ONLINE' : '[●] NETWORK: OFFLINE';
statusDiv.className = `network-status ${isOnline ? 'network-online' : 'network-offline'}`;
}
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
// Get EventManager
function getEventManager() {
if (!window.calendarDebug?.eventManager) {
throw new Error('Calendar not loaded - window.calendarDebug.eventManager not available');
}
return window.calendarDebug.eventManager;
}
// Create Timed Event
async function createTimedEvent() {
const result = document.getElementById('createResult');
try {
const title = document.getElementById('createTitle').value;
const start = new Date(document.getElementById('createStart').value);
const end = new Date(document.getElementById('createEnd').value);
const eventManager = getEventManager();
const newEvent = await eventManager.addEvent({
title,
start,
end,
type: 'meeting',
allDay: false,
syncStatus: 'pending'
});
showResult(result, 'success', `[OK] Event created<br>ID: ${newEvent.id}<br>Status: ${newEvent.syncStatus}`);
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Create All-Day Event
async function createAllDayEvent() {
const result = document.getElementById('createAllDayResult');
try {
const title = document.getElementById('createAllDayTitle').value;
const dateStr = document.getElementById('createAllDayDate').value;
const start = new Date(dateStr + 'T00:00:00');
const end = new Date(dateStr + 'T23:59:59');
const eventManager = getEventManager();
const newEvent = await eventManager.addEvent({
title,
start,
end,
type: 'holiday',
allDay: true,
syncStatus: 'pending'
});
showResult(result, 'success', `[OK] All-day event created<br>ID: ${newEvent.id}`);
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Update Event Title
async function updateEventTitle() {
const result = document.getElementById('updateTitleResult');
try {
const eventId = document.getElementById('updateEventId').value;
const newTitle = document.getElementById('updateTitle').value;
if (!eventId) {
showResult(result, 'error', '[ERROR] Please enter an event ID');
return;
}
const eventManager = getEventManager();
const updated = await eventManager.updateEvent(eventId, { title: newTitle });
if (updated) {
showResult(result, 'success', `[OK] Event updated<br>New title: "${updated.title}"`);
await refreshPreview();
} else {
showResult(result, 'error', '[ERROR] Event not found');
}
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Toggle All-Day Status
async function toggleAllDay() {
const result = document.getElementById('toggleResult');
try {
const eventId = document.getElementById('toggleEventId').value;
if (!eventId) {
showResult(result, 'error', '[ERROR] Please enter an event ID');
return;
}
const eventManager = getEventManager();
const event = await eventManager.getEventById(eventId);
if (!event) {
showResult(result, 'error', '[ERROR] Event not found');
return;
}
const updated = await eventManager.updateEvent(eventId, {
allDay: !event.allDay
});
showResult(result, 'success', `[OK] Event toggled<br>Now: ${updated.allDay ? 'all-day' : 'timed'}`);
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Delete Event
async function deleteEvent() {
const result = document.getElementById('deleteResult');
try {
const eventId = document.getElementById('deleteEventId').value;
if (!eventId) {
showResult(result, 'error', '[ERROR] Please enter an event ID');
return;
}
if (!confirm(`Delete event ${eventId}?`)) {
return;
}
const eventManager = getEventManager();
const success = await eventManager.deleteEvent(eventId);
if (success) {
showResult(result, 'success', `[OK] Event deleted<br>ID: ${eventId}`);
await refreshPreview();
} else {
showResult(result, 'error', '[ERROR] Event not found');
}
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// List All Events
async function listAllEvents() {
const result = document.getElementById('utilityResult');
try {
const db = window.calendarDebug.indexedDB;
const events = await db.getAllEvents();
const html = `
<strong>[EVENTS] Total: ${events.length}</strong><br><br>
${events.map(e => `
<div style="margin-bottom: 10px; padding: 10px; background: var(--bg-secondary); border: 2px solid var(--border-color);">
<strong>${e.title}</strong><br>
<span style="font-size: 10px; color: var(--text-muted);">ID: ${e.id}</span><br>
<span class="badge badge-${e.syncStatus === 'synced' ? 'success' : e.syncStatus === 'pending' ? 'warning' : 'error'}">
${e.syncStatus}
</span>
${e.allDay ? '[ALL-DAY]' : '[TIMED]'}
</div>
`).join('')}
`;
showResult(result, 'info', html);
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Show Queue
async function showQueue() {
const result = document.getElementById('utilityResult');
try {
const queue = window.calendarDebug.queue;
const items = await queue.getAll();
const html = `
<strong>[QUEUE] Size: ${items.length}</strong><br><br>
${items.length === 0 ? '[INFO] Queue is empty' : items.map(item => `
<div style="margin-bottom: 10px; padding: 10px; background: var(--bg-secondary); border: 2px solid var(--border-color); border-left: 3px solid var(--accent-primary);">
<strong>${item.type.toUpperCase()}</strong> → Event ${item.eventId}<br>
<span style="font-size: 10px; color: var(--text-muted);">Retry: ${item.retryCount}/5</span>
</div>
`).join('')}
`;
showResult(result, 'info', html);
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Trigger Sync
async function triggerSync() {
const result = document.getElementById('utilityResult');
const timestamp = new Date().toLocaleTimeString('da-DK');
try {
const syncManager = window.calendarDebug.syncManager;
await syncManager.triggerManualSync();
showResult(result, 'success', `[OK] Sync triggered at ${timestamp}<br>Check sync-visualization.html for details`);
await refreshPreview();
} catch (error) {
const isOffline = error.message.includes('offline');
const icon = isOffline ? '⚠️' : '❌';
showResult(result, 'error', `${icon} [ERROR] Sync failed at ${timestamp}<br>${error.message}`);
}
}
// Clear All Data
async function clearAllData() {
if (!confirm('⚠️ WARNING: Delete ALL events and queue? This cannot be undone!')) {
return;
}
const result = document.getElementById('utilityResult');
try {
const db = window.calendarDebug.indexedDB;
const queue = window.calendarDebug.queue;
await queue.clear();
const events = await db.getAllEvents();
for (const event of events) {
await db.deleteEvent(event.id);
}
showResult(result, 'success', '[OK] All data cleared');
await refreshPreview();
} catch (error) {
showResult(result, 'error', `[ERROR] ${error.message}`);
}
}
// Refresh Event Preview
async function refreshPreview() {
const preview = document.getElementById('eventPreview');
try {
const db = window.calendarDebug.indexedDB;
const events = await db.getAllEvents();
if (events.length === 0) {
preview.innerHTML = '<div style="grid-column: 1/-1; text-align: center; color: var(--text-muted); padding: 40px; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600;">[EMPTY] No events in IndexedDB</div>';
return;
}
preview.innerHTML = events.map(e => `
<div class="event-preview-item">
<div class="event-preview-title">${e.title}</div>
<div class="event-preview-id">${e.id}</div>
<div class="event-preview-status">
<span class="badge badge-${e.syncStatus === 'synced' ? 'success' : e.syncStatus === 'pending' ? 'warning' : 'error'}">
${e.syncStatus}
</span>
<span>${e.allDay ? '[ALL-DAY]' : '[TIMED]'}</span>
</div>
</div>
`).join('');
} catch (error) {
preview.innerHTML = `<div style="color: var(--error);">[ERROR] ${error.message}</div>`;
}
}
// Show Result Helper
function showResult(element, type, message) {
element.innerHTML = `<div class="result-box result-${type}">${message}</div>`;
}
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', async () => {
initDateTimeInputs();
updateNetworkStatus();
await waitForCalendar();
refreshPreview();
});
} else {
(async () => {
initDateTimeInputs();
updateNetworkStatus();
await waitForCalendar();
refreshPreview();
})();
}
</script>
</body>
</html>

View file

@ -0,0 +1,854 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SYNC QUEUE VISUALIZATION | Calendar System</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f3f5;
--border-color: #dee2e6;
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--accent-primary: #0066cc;
--accent-secondary: #6610f2;
--success: #28a745;
--warning: #ffc107;
--error: #dc3545;
--info: #17a2b8;
}
body {
font-family: 'JetBrains Mono', 'Courier New', monospace;
background: var(--bg-primary);
color: var(--text-primary);
padding: 20px;
line-height: 1.6;
font-size: 13px;
}
.header {
background: var(--bg-secondary);
padding: 24px;
border: 2px solid var(--border-color);
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
}
h1 {
color: var(--text-primary);
margin-bottom: 6px;
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
h1::before {
content: '▶ ';
color: var(--accent-primary);
}
.subtitle {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 20px;
font-weight: 400;
}
.status-bar {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 12px 0;
border-top: 2px solid var(--border-color);
border-bottom: 2px solid var(--border-color);
margin: 16px 0;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-label {
color: var(--text-muted);
}
.status-badge {
padding: 4px 12px;
border-radius: 2px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.8px;
border: 1px solid;
}
.status-online {
background: var(--success);
color: white;
border-color: var(--success);
}
.status-offline {
background: var(--error);
color: white;
border-color: var(--error);
}
.status-syncing {
background: var(--warning);
color: #212529;
border-color: var(--warning);
}
.status-idle {
background: var(--text-muted);
color: white;
border-color: var(--text-muted);
}
.controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
padding: 8px 14px;
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.15s ease;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
}
button:hover {
border-color: var(--accent-primary);
background: var(--bg-tertiary);
color: var(--accent-primary);
}
button:active {
transform: scale(0.98);
}
.btn-primary {
border-color: var(--info);
color: var(--info);
}
.btn-success {
border-color: var(--success);
color: var(--success);
}
.btn-danger {
border-color: var(--error);
color: var(--error);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.panel {
background: var(--bg-secondary);
border: 2px solid var(--border-color);
position: relative;
}
.panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
}
.panel-title {
font-size: 12px;
font-weight: 600;
color: var(--accent-primary);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid var(--border-color);
text-transform: uppercase;
letter-spacing: 1px;
background: var(--bg-tertiary);
}
.count-badge {
background: var(--bg-secondary);
color: var(--text-primary);
padding: 4px 10px;
border: 2px solid var(--border-color);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
}
.event-list {
max-height: 400px;
overflow-y: auto;
padding: 12px;
}
.event-list::-webkit-scrollbar {
width: 10px;
}
.event-list::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
.event-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border: 2px solid var(--bg-tertiary);
}
.event-list::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}
.event-item {
padding: 12px;
border: 2px solid var(--border-color);
margin-bottom: 8px;
transition: all 0.15s ease;
background: var(--bg-tertiary);
}
.event-item:hover {
border-color: var(--accent-primary);
transform: translateX(2px);
box-shadow: 2px 0 0 var(--accent-primary);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.event-title {
font-weight: 600;
color: var(--text-primary);
font-size: 12px;
}
.sync-status {
padding: 3px 10px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
border: 2px solid;
}
.sync-synced {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.sync-pending {
background: #fff3cd;
color: #856404;
border-color: var(--warning);
}
.sync-error {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.event-details {
font-size: 11px;
color: var(--text-muted);
line-height: 1.6;
}
.queue-item {
padding: 12px;
border-left: 3px solid var(--accent-primary);
background: var(--bg-tertiary);
margin-bottom: 8px;
border: 2px solid var(--border-color);
border-left: 3px solid var(--accent-primary);
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.operation-type {
padding: 3px 10px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
border: 2px solid;
}
.op-create {
background: #d4edda;
color: var(--success);
border-color: var(--success);
}
.op-update {
background: #d1ecf1;
color: var(--info);
border-color: var(--info);
}
.op-delete {
background: #f8d7da;
color: var(--error);
border-color: var(--error);
}
.retry-count {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
}
.log-panel {
grid-column: 1 / -1;
}
.log-list {
max-height: 300px;
overflow-y: auto;
background: var(--bg-tertiary);
padding: 16px;
font-size: 11px;
border: 2px solid var(--border-color);
}
.log-entry {
margin-bottom: 4px;
padding: 8px;
border-left: 3px solid transparent;
padding-left: 12px;
background: var(--bg-secondary);
}
.log-entry:hover {
background: var(--bg-primary);
}
.log-timestamp {
color: var(--text-muted);
margin-right: 12px;
font-weight: 600;
}
.log-info { border-left-color: var(--info); }
.log-success { border-left-color: var(--success); }
.log-warning { border-left-color: var(--warning); }
.log-error { border-left-color: var(--error); }
.log-info .log-message { color: var(--info); }
.log-success .log-message { color: var(--success); }
.log-warning .log-message { color: #856404; }
.log-error .log-message { color: var(--error); }
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
padding: 16px;
}
.stat-item {
text-align: center;
padding: 16px;
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
position: relative;
}
.stat-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent-primary);
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--accent-primary);
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.refresh-indicator {
display: inline-block;
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
margin-left: 8px;
animation: pulse 2s infinite;
box-shadow: 0 0 8px var(--success);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
<div class="header">
<h1>SYNC QUEUE VISUALIZATION</h1>
<p class="subtitle">// Live monitoring of offline-first calendar sync operations</p>
<div id="initStatus" style="background: var(--warning); color: #000; padding: 12px; margin-bottom: 16px; border: 2px solid var(--border-color); text-align: center; font-weight: 600; letter-spacing: 0.5px;">
[⏳] INITIALIZING CALENDAR SYSTEM...
</div>
<div class="status-bar">
<div class="status-item">
<span class="status-label">NETWORK:</span>
<span id="networkStatus" class="status-badge status-online">ONLINE</span>
</div>
<div class="status-item">
<span class="status-label">SYNC:</span>
<span id="syncStatus" class="status-badge status-idle">IDLE</span>
</div>
<div class="status-item">
<span class="status-label">AUTO-REFRESH:</span>
<span class="refresh-indicator"></span>
</div>
<div class="status-item">
<span class="status-label">LAST SYNC:</span>
<span id="lastSyncTime" class="status-badge status-idle">NEVER</span>
</div>
</div>
<div class="controls">
<button class="btn-primary" onclick="manualSync()">TRIGGER SYNC</button>
<button class="btn-success" onclick="refreshData()">REFRESH DATA</button>
<button onclick="toggleNetworkSimulator()">TOGGLE NETWORK</button>
<button class="btn-danger" onclick="clearQueue()">CLEAR QUEUE</button>
<button class="btn-danger" onclick="clearDatabase()">CLEAR DATABASE</button>
</div>
</div>
<div class="grid">
<!-- IndexedDB Events -->
<div class="panel">
<div class="panel-title">
<span>INDEXEDDB EVENTS</span>
<span id="eventCount" class="count-badge">0</span>
</div>
<div id="eventList" class="event-list"></div>
</div>
<!-- Operation Queue -->
<div class="panel">
<div class="panel-title">
<span>OPERATION QUEUE</span>
<span id="queueCount" class="count-badge">0</span>
</div>
<div id="queueList" class="event-list"></div>
</div>
<!-- Statistics -->
<div class="panel">
<div class="panel-title">
<span>STATISTICS</span>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value" id="statSynced">0</div>
<div class="stat-label">Synced</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statPending">0</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statError">0</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statQueue">0</div>
<div class="stat-label">In Queue</div>
</div>
</div>
</div>
<!-- Sync Log -->
<div class="panel log-panel">
<div class="panel-title">
<span>SYNC LOG</span>
<button onclick="clearLog()" style="font-size: 10px; padding: 4px 8px;">CLEAR</button>
</div>
<div id="logList" class="log-list"></div>
</div>
</div>
<!-- Load Test Initialization Script -->
<script src="test-init.js"></script>
<script>
let logEntries = [];
const MAX_LOG_ENTRIES = 100;
let calendarReady = false;
// Wait for calendar to initialize
function waitForCalendar() {
return new Promise((resolve, reject) => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.display = 'none';
}
resolve();
return;
}
const checkInterval = setInterval(() => {
if (window.calendarDebug?.indexedDB) {
calendarReady = true;
clearInterval(checkInterval);
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.background = 'var(--success)';
initStatus.style.color = '#fff';
initStatus.textContent = '[✓] CALENDAR SYSTEM READY';
setTimeout(() => {
initStatus.style.display = 'none';
}, 1000);
}
resolve();
}
}, 100);
// Timeout after 10 seconds
setTimeout(() => {
if (!calendarReady) {
clearInterval(checkInterval);
const initStatus = document.getElementById('initStatus');
if (initStatus) {
initStatus.style.background = 'var(--error)';
initStatus.style.color = '#fff';
initStatus.textContent = '[✗] CALENDAR SYSTEM FAILED TO INITIALIZE - Check console for details';
}
reject(new Error('Calendar failed to initialize within 10 seconds'));
}
}, 10000);
});
}
// Initialize
async function init() {
log('info', 'Waiting for calendar system to initialize...');
try {
await waitForCalendar();
log('success', 'Connected to calendar IndexedDB');
} catch (error) {
log('error', 'Calendar system failed to initialize: ' + error.message);
return;
}
// Listen to network events
window.addEventListener('online', () => {
updateNetworkStatus(true);
log('success', 'Network online');
});
window.addEventListener('offline', () => {
updateNetworkStatus(false);
log('warning', 'Network offline');
});
// Initial load
await refreshData();
// Auto-refresh every 2 seconds
setInterval(refreshData, 2000);
}
async function refreshData() {
try {
const db = window.calendarDebug.indexedDB;
const queue = window.calendarDebug.queue;
if (!db || !queue) {
log('error', 'IndexedDB or Queue not available');
return;
}
// Get events
const events = await db.getAllEvents();
renderEvents(events);
// Get queue
const queueItems = await queue.getAll();
renderQueue(queueItems);
// Update statistics
updateStatistics(events, queueItems);
} catch (error) {
log('error', `Refresh failed: ${error.message}`);
}
}
function renderEvents(events) {
const container = document.getElementById('eventList');
document.getElementById('eventCount').textContent = events.length;
if (events.length === 0) {
container.innerHTML = '<div class="empty-state">No events in IndexedDB</div>';
return;
}
container.innerHTML = events.map(event => `
<div class="event-item">
<div class="event-header">
<span class="event-title">${event.title}</span>
<span class="sync-status sync-${event.syncStatus}">${event.syncStatus}</span>
</div>
<div class="event-details">
ID: ${event.id}<br>
${event.allDay ? 'ALL-DAY' : formatTime(event.start) + ' - ' + formatTime(event.end)}
</div>
</div>
`).join('');
}
function renderQueue(queueItems) {
const container = document.getElementById('queueList');
document.getElementById('queueCount').textContent = queueItems.length;
if (queueItems.length === 0) {
container.innerHTML = '<div class="empty-state">Queue is empty</div>';
return;
}
container.innerHTML = queueItems.map(item => `
<div class="queue-item">
<div class="queue-header">
<span class="operation-type op-${item.type}">${item.type}</span>
<span class="retry-count">RETRY: ${item.retryCount}/5</span>
</div>
<div class="event-details">
EVENT ID: ${item.eventId}<br>
TIMESTAMP: ${new Date(item.timestamp).toLocaleTimeString('da-DK')}
</div>
</div>
`).join('');
}
function updateStatistics(events, queueItems) {
const synced = events.filter(e => e.syncStatus === 'synced').length;
const pending = events.filter(e => e.syncStatus === 'pending').length;
const error = events.filter(e => e.syncStatus === 'error').length;
document.getElementById('statSynced').textContent = synced;
document.getElementById('statPending').textContent = pending;
document.getElementById('statError').textContent = error;
document.getElementById('statQueue').textContent = queueItems.length;
}
function updateNetworkStatus(isOnline) {
const badge = document.getElementById('networkStatus');
badge.textContent = isOnline ? 'ONLINE' : 'OFFLINE';
badge.className = `status-badge ${isOnline ? 'status-online' : 'status-offline'}`;
}
function updateSyncStatus(isSyncing) {
const badge = document.getElementById('syncStatus');
badge.textContent = isSyncing ? 'SYNCING' : 'IDLE';
badge.className = `status-badge ${isSyncing ? 'status-syncing' : 'status-idle'}`;
}
async function manualSync() {
const timestamp = new Date().toLocaleTimeString('da-DK');
log('info', `Manual sync triggered at ${timestamp}`);
updateSyncStatus(true);
try {
const syncManager = window.calendarDebug.syncManager;
if (syncManager) {
await syncManager.triggerManualSync();
log('success', `Manual sync completed at ${timestamp}`);
updateLastSyncTime(timestamp, 'success');
} else {
log('error', 'SyncManager not available');
updateLastSyncTime(timestamp, 'error');
}
} catch (error) {
log('error', `Manual sync failed: ${error.message}`);
updateLastSyncTime(timestamp, 'error');
} finally {
updateSyncStatus(false);
await refreshData();
}
}
function updateLastSyncTime(timestamp, status = 'success') {
const badge = document.getElementById('lastSyncTime');
badge.textContent = timestamp;
badge.className = `status-badge status-${status}`;
}
async function clearQueue() {
if (!confirm('Clear all operations from the queue?')) return;
log('warning', 'Clearing queue...');
try {
const queue = window.calendarDebug.queue;
await queue.clear();
log('success', 'Queue cleared');
await refreshData();
} catch (error) {
log('error', `Failed to clear queue: ${error.message}`);
}
}
async function clearDatabase() {
if (!confirm('⚠️ WARNING: This will delete ALL events from IndexedDB! Continue?')) return;
log('warning', 'Clearing database...');
try {
const db = window.calendarDebug.indexedDB;
db.close();
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase('CalendarDB');
request.onsuccess = resolve;
request.onerror = reject;
});
log('success', 'Database cleared - please reload the page');
alert('Database cleared! Please reload the page.');
} catch (error) {
log('error', `Failed to clear database: ${error.message}`);
}
}
function toggleNetworkSimulator() {
const isCurrentlyOnline = navigator.onLine;
log('info', `Network simulator toggle (currently ${isCurrentlyOnline ? 'online' : 'offline'})`);
log('warning', 'Use DevTools > Network > Offline for real offline testing');
}
function log(level, message) {
const timestamp = new Date().toLocaleTimeString('da-DK');
const entry = { timestamp, level, message };
logEntries.unshift(entry);
if (logEntries.length > MAX_LOG_ENTRIES) {
logEntries.pop();
}
renderLog();
}
function renderLog() {
const container = document.getElementById('logList');
container.innerHTML = logEntries.map(entry => `
<div class="log-entry log-${entry.level}">
<span class="log-timestamp">[${entry.timestamp}]</span>
<span class="log-message">${entry.message}</span>
</div>
`).join('');
}
function clearLog() {
logEntries = [];
renderLog();
log('info', 'Log cleared');
}
function formatTime(date) {
return new Date(date).toLocaleTimeString('da-DK', {
hour: '2-digit',
minute: '2-digit'
});
}
// Start on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
</body>
</html>

View file

@ -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"
}
}
]

View file

@ -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);
});
}