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:
parent
9c765b35ab
commit
e7011526e3
20 changed files with 3822 additions and 57 deletions
|
|
@ -19,11 +19,12 @@ export const CoreEvents = {
|
||||||
PERIOD_INFO_UPDATE: 'nav:period-info-update',
|
PERIOD_INFO_UPDATE: 'nav:period-info-update',
|
||||||
NAVIGATE_TO_EVENT: 'nav:navigate-to-event',
|
NAVIGATE_TO_EVENT: 'nav:navigate-to-event',
|
||||||
|
|
||||||
// Data events (4)
|
// Data events (5)
|
||||||
DATA_LOADING: 'data:loading',
|
DATA_LOADING: 'data:loading',
|
||||||
DATA_LOADED: 'data:loaded',
|
DATA_LOADED: 'data:loaded',
|
||||||
DATA_ERROR: 'data:error',
|
DATA_ERROR: 'data:error',
|
||||||
EVENTS_FILTERED: 'data:events-filtered',
|
EVENTS_FILTERED: 'data:events-filtered',
|
||||||
|
REMOTE_UPDATE_RECEIVED: 'data:remote-update',
|
||||||
|
|
||||||
// Grid events (3)
|
// Grid events (3)
|
||||||
GRID_RENDERED: 'grid:rendered',
|
GRID_RENDERED: 'grid:rendered',
|
||||||
|
|
@ -36,9 +37,16 @@ export const CoreEvents = {
|
||||||
EVENT_DELETED: 'event:deleted',
|
EVENT_DELETED: 'event:deleted',
|
||||||
EVENT_SELECTED: 'event:selected',
|
EVENT_SELECTED: 'event:selected',
|
||||||
|
|
||||||
// System events (2)
|
// System events (3)
|
||||||
ERROR: 'system:error',
|
ERROR: 'system:error',
|
||||||
REFRESH_REQUESTED: 'system:refresh',
|
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 events (1)
|
||||||
FILTER_CHANGED: 'filter:changed',
|
FILTER_CHANGED: 'filter:changed',
|
||||||
|
|
|
||||||
51
src/index.ts
51
src/index.ts
|
|
@ -21,9 +21,16 @@ import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
||||||
import { DragHoverManager } from './managers/DragHoverManager';
|
import { DragHoverManager } from './managers/DragHoverManager';
|
||||||
import { HeaderManager } from './managers/HeaderManager';
|
import { HeaderManager } from './managers/HeaderManager';
|
||||||
|
|
||||||
// Import repositories
|
// Import repositories and storage
|
||||||
import { IEventRepository } from './repositories/IEventRepository';
|
import { IEventRepository } from './repositories/IEventRepository';
|
||||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
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 renderers
|
||||||
import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer';
|
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}`);
|
console.log(`Deep linking to event ID: ${eventId}`);
|
||||||
|
|
||||||
// Wait a bit for managers to be fully ready
|
// Wait a bit for managers to be fully ready
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
const success = eventManager.navigateToEvent(eventId);
|
const success = await eventManager.navigateToEvent(eventId);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.warn(`Deep linking failed: Event with ID ${eventId} not found`);
|
console.warn(`Deep linking failed: Event with ID ${eventId} not found`);
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +80,22 @@ async function initializeCalendar(): Promise<void> {
|
||||||
// Load configuration from JSON
|
// Load configuration from JSON
|
||||||
const config = await ConfigManager.load();
|
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
|
// Create NovaDI container
|
||||||
const container = new Container();
|
const container = new Container();
|
||||||
const builder = container.builder();
|
const builder = container.builder();
|
||||||
|
|
@ -86,8 +109,13 @@ async function initializeCalendar(): Promise<void> {
|
||||||
// Register configuration instance
|
// Register configuration instance
|
||||||
builder.registerInstance(config).as<Configuration>();
|
builder.registerInstance(config).as<Configuration>();
|
||||||
|
|
||||||
// Register repositories
|
// Register IndexedDB and storage instances
|
||||||
builder.registerType(MockEventRepository).as<IEventRepository>();
|
builder.registerInstance(indexedDB).as<IndexedDBService>();
|
||||||
|
builder.registerInstance(queue).as<OperationQueue>();
|
||||||
|
builder.registerInstance(apiRepository).as<ApiEventRepository>();
|
||||||
|
|
||||||
|
// Register repository
|
||||||
|
builder.registerInstance(repository).as<IEventRepository>();
|
||||||
|
|
||||||
// Register renderers
|
// Register renderers
|
||||||
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
||||||
|
|
@ -143,6 +171,13 @@ async function initializeCalendar(): Promise<void> {
|
||||||
await calendarManager.initialize?.();
|
await calendarManager.initialize?.();
|
||||||
await resizeHandleManager.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
|
// Handle deep linking after managers are initialized
|
||||||
await handleDeepLinking(eventManager, urlManager);
|
await handleDeepLinking(eventManager, urlManager);
|
||||||
|
|
||||||
|
|
@ -153,12 +188,18 @@ async function initializeCalendar(): Promise<void> {
|
||||||
app: typeof app;
|
app: typeof app;
|
||||||
calendarManager: typeof calendarManager;
|
calendarManager: typeof calendarManager;
|
||||||
eventManager: typeof eventManager;
|
eventManager: typeof eventManager;
|
||||||
|
syncManager: typeof syncManager;
|
||||||
|
indexedDB: typeof indexedDB;
|
||||||
|
queue: typeof queue;
|
||||||
};
|
};
|
||||||
}).calendarDebug = {
|
}).calendarDebug = {
|
||||||
eventBus,
|
eventBus,
|
||||||
app,
|
app,
|
||||||
calendarManager,
|
calendarManager,
|
||||||
eventManager,
|
eventManager,
|
||||||
|
syncManager,
|
||||||
|
indexedDB,
|
||||||
|
queue,
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -127,13 +127,13 @@ export class AllDayManager {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for header ready - when dates are populated with period data
|
// 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 headerReadyEventPayload = (event as CustomEvent<IHeaderReadyEventPayload>).detail;
|
||||||
|
|
||||||
let startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.date);
|
let startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.date);
|
||||||
let endDate = new Date(headerReadyEventPayload.headerElements.at(-1)!.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
|
// Filter for all-day events
|
||||||
const allDayEvents = events.filter(event => event.allDay);
|
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 => {
|
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
|
// 7. Apply highlight class to show the dropped event with highlight color
|
||||||
dragEndEvent.draggedClone.classList.add('highlight');
|
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.fadeOutAndRemove(dragEndEvent.originalElement);
|
||||||
|
|
||||||
this.checkAndAnimateAllDayHeight();
|
this.checkAndAnimateAllDayHeight();
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ export class CalendarManager {
|
||||||
/**
|
/**
|
||||||
* Re-render events after grid structure changes
|
* Re-render events after grid structure changes
|
||||||
*/
|
*/
|
||||||
private rerenderEvents(): void {
|
private async rerenderEvents(): Promise<void> {
|
||||||
|
|
||||||
// Get current period data to determine date range
|
// Get current period data to determine date range
|
||||||
const periodData = this.calculateCurrentPeriod();
|
const periodData = this.calculateCurrentPeriod();
|
||||||
|
|
@ -223,7 +223,7 @@ export class CalendarManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger event rendering for the current date range using correct method
|
// Trigger event rendering for the current date range using correct method
|
||||||
this.eventRenderer.renderEvents({
|
await this.eventRenderer.renderEvents({
|
||||||
container: container as HTMLElement,
|
container: container as HTMLElement,
|
||||||
startDate: new Date(periodData.start),
|
startDate: new Date(periodData.start),
|
||||||
endDate: new Date(periodData.end)
|
endDate: new Date(periodData.end)
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import { IEventRepository } from '../repositories/IEventRepository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventManager - Event lifecycle and CRUD operations
|
* 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 {
|
export class EventManager {
|
||||||
|
|
||||||
private events: ICalendarEvent[] = [];
|
|
||||||
private dateService: DateService;
|
private dateService: DateService;
|
||||||
private config: Configuration;
|
private config: Configuration;
|
||||||
private repository: IEventRepository;
|
private repository: IEventRepository;
|
||||||
|
|
@ -28,30 +28,32 @@ export class EventManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load event data from repository
|
* Load event data from repository
|
||||||
|
* No longer caches - delegates to repository
|
||||||
*/
|
*/
|
||||||
public async loadData(): Promise<void> {
|
public async loadData(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.events = await this.repository.loadEvents();
|
// Just ensure repository is ready - no caching
|
||||||
|
await this.repository.loadEvents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load event data:', error);
|
console.error('Failed to load event data:', error);
|
||||||
this.events = [];
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get events with optional copying for performance
|
* Get all events from repository
|
||||||
*/
|
*/
|
||||||
public getEvents(copy: boolean = false): ICalendarEvent[] {
|
public async getEvents(copy: boolean = false): Promise<ICalendarEvent[]> {
|
||||||
return copy ? [...this.events] : this.events;
|
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 {
|
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
|
||||||
// Use find for better performance than filter + first
|
const events = await this.repository.loadEvents();
|
||||||
return this.events.find(event => event.id === id);
|
return events.find(event => event.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,8 +61,8 @@ export class EventManager {
|
||||||
* @param id Event ID to find
|
* @param id Event ID to find
|
||||||
* @returns Event with navigation info or null if not found
|
* @returns Event with navigation info or null if not found
|
||||||
*/
|
*/
|
||||||
public getEventForNavigation(id: string): { event: ICalendarEvent; eventDate: Date } | null {
|
public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> {
|
||||||
const event = this.getEventById(id);
|
const event = await this.getEventById(id);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -90,8 +92,8 @@ export class EventManager {
|
||||||
* @param eventId Event ID to navigate to
|
* @param eventId Event ID to navigate to
|
||||||
* @returns true if event found and navigation initiated, false otherwise
|
* @returns true if event found and navigation initiated, false otherwise
|
||||||
*/
|
*/
|
||||||
public navigateToEvent(eventId: string): boolean {
|
public async navigateToEvent(eventId: string): Promise<boolean> {
|
||||||
const eventInfo = this.getEventForNavigation(eventId);
|
const eventInfo = await this.getEventForNavigation(eventId);
|
||||||
if (!eventInfo) {
|
if (!eventInfo) {
|
||||||
console.warn(`EventManager: Event with ID ${eventId} not found`);
|
console.warn(`EventManager: Event with ID ${eventId} not found`);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -113,23 +115,20 @@ export class EventManager {
|
||||||
/**
|
/**
|
||||||
* Get events that overlap with a given time period
|
* 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
|
// 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;
|
return event.start <= endDate && event.end >= startDate;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new event and add it to the calendar
|
* Create a new event and add it to the calendar
|
||||||
|
* Delegates to repository with source='local'
|
||||||
*/
|
*/
|
||||||
public addEvent(event: Omit<ICalendarEvent, 'id'>): ICalendarEvent {
|
public async addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
|
||||||
const newEvent: ICalendarEvent = {
|
const newEvent = await this.repository.createEvent(event, 'local');
|
||||||
...event,
|
|
||||||
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
|
||||||
};
|
|
||||||
|
|
||||||
this.events.push(newEvent);
|
|
||||||
|
|
||||||
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
|
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
|
||||||
event: newEvent
|
event: newEvent
|
||||||
|
|
@ -140,18 +139,59 @@ export class EventManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing event
|
* Update an existing event
|
||||||
|
* Delegates to repository with source='local'
|
||||||
*/
|
*/
|
||||||
public updateEvent(id: string, updates: Partial<ICalendarEvent>): ICalendarEvent | null {
|
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
|
||||||
const eventIndex = this.events.findIndex(event => event.id === id);
|
try {
|
||||||
if (eventIndex === -1) return null;
|
const updatedEvent = await this.repository.updateEvent(id, updates, 'local');
|
||||||
|
|
||||||
const updatedEvent = { ...this.events[eventIndex], ...updates };
|
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||||
this.events[eventIndex] = updatedEvent;
|
event: updatedEvent
|
||||||
|
});
|
||||||
|
|
||||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
return updatedEvent;
|
||||||
event: 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,12 @@ export class EventRenderingService {
|
||||||
/**
|
/**
|
||||||
* Render events in a specific container for a given period
|
* 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
|
// Clear existing events in the specific container first
|
||||||
this.strategy.clearEvents(context.container);
|
this.strategy.clearEvents(context.container);
|
||||||
|
|
||||||
// Get events from EventManager for the period
|
// Get events from EventManager for the period
|
||||||
const events = this.eventManager.getEventsForPeriod(
|
const events = await this.eventManager.getEventsForPeriod(
|
||||||
context.startDate,
|
context.startDate,
|
||||||
context.endDate
|
context.endDate
|
||||||
);
|
);
|
||||||
|
|
@ -159,7 +159,7 @@ export class EventRenderingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupDragEndListener(): void {
|
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 { originalElement: draggedElement, sourceColumn, finalPosition, target } = (event as CustomEvent<IDragEndEventPayload>).detail;
|
||||||
const finalColumn = finalPosition.column;
|
const finalColumn = finalPosition.column;
|
||||||
|
|
@ -181,7 +181,7 @@ export class EventRenderingService {
|
||||||
const newStart = swpEvent.start;
|
const newStart = swpEvent.start;
|
||||||
const newEnd = swpEvent.end;
|
const newEnd = swpEvent.end;
|
||||||
|
|
||||||
this.eventManager.updateEvent(eventId, {
|
await this.eventManager.updateEvent(eventId, {
|
||||||
start: newStart,
|
start: newStart,
|
||||||
end: newEnd
|
end: newEnd
|
||||||
});
|
});
|
||||||
|
|
@ -262,7 +262,7 @@ export class EventRenderingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupResizeEndListener(): void {
|
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;
|
const { eventId, element } = (event as CustomEvent<IResizeEndEventPayload>).detail;
|
||||||
|
|
||||||
// Update event data in EventManager with new end time from resized element
|
// Update event data in EventManager with new end time from resized element
|
||||||
|
|
@ -270,7 +270,7 @@ export class EventRenderingService {
|
||||||
const newStart = swpEvent.start;
|
const newStart = swpEvent.start;
|
||||||
const newEnd = swpEvent.end;
|
const newEnd = swpEvent.end;
|
||||||
|
|
||||||
this.eventManager.updateEvent(eventId, {
|
await this.eventManager.updateEvent(eventId, {
|
||||||
start: newStart,
|
start: newStart,
|
||||||
end: newEnd
|
end: newEnd
|
||||||
});
|
});
|
||||||
|
|
|
||||||
129
src/repositories/ApiEventRepository.ts
Normal file
129
src/repositories/ApiEventRepository.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
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
|
* 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:
|
* 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
|
* - ApiEventRepository: (Future) Loads from backend API
|
||||||
*/
|
*/
|
||||||
export interface IEventRepository {
|
export interface IEventRepository {
|
||||||
|
|
@ -17,4 +25,32 @@ export interface IEventRepository {
|
||||||
* @throws Error if loading fails
|
* @throws Error if loading fails
|
||||||
*/
|
*/
|
||||||
loadEvents(): Promise<ICalendarEvent[]>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
145
src/repositories/IndexedDBEventRepository.ts
Normal file
145
src/repositories/IndexedDBEventRepository.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||||
import { IEventRepository } from './IEventRepository';
|
import { IEventRepository, UpdateSource } from './IEventRepository';
|
||||||
|
|
||||||
interface RawEventData {
|
interface RawEventData {
|
||||||
id: string;
|
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.
|
* 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
|
* 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 {
|
export class MockEventRepository implements IEventRepository {
|
||||||
private readonly dataUrl = 'data/mock-events.json';
|
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[] {
|
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
||||||
return data.map((event): ICalendarEvent => ({
|
return data.map((event): ICalendarEvent => ({
|
||||||
...event,
|
...event,
|
||||||
|
|
|
||||||
401
src/storage/IndexedDBService.ts
Normal file
401
src/storage/IndexedDBService.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/storage/OperationQueue.ts
Normal file
111
src/storage/OperationQueue.ts
Normal 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
276
src/workers/SyncManager.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
130
test/integrationtesting/README.md
Normal file
130
test/integrationtesting/README.md
Normal 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.
|
||||||
974
test/integrationtesting/offline-test.html
Normal file
974
test/integrationtesting/offline-test.html
Normal 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>
|
||||||
854
test/integrationtesting/sync-visualization.html
Normal file
854
test/integrationtesting/sync-visualization.html
Normal 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>
|
||||||
132
test/integrationtesting/test-events.json
Normal file
132
test/integrationtesting/test-events.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
452
test/integrationtesting/test-init.js
Normal file
452
test/integrationtesting/test-init.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue