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',
|
||||
NAVIGATE_TO_EVENT: 'nav:navigate-to-event',
|
||||
|
||||
// Data events (4)
|
||||
// Data events (5)
|
||||
DATA_LOADING: 'data:loading',
|
||||
DATA_LOADED: 'data:loaded',
|
||||
DATA_ERROR: 'data:error',
|
||||
EVENTS_FILTERED: 'data:events-filtered',
|
||||
REMOTE_UPDATE_RECEIVED: 'data:remote-update',
|
||||
|
||||
// Grid events (3)
|
||||
GRID_RENDERED: 'grid:rendered',
|
||||
|
|
@ -36,9 +37,16 @@ export const CoreEvents = {
|
|||
EVENT_DELETED: 'event:deleted',
|
||||
EVENT_SELECTED: 'event:selected',
|
||||
|
||||
// System events (2)
|
||||
// System events (3)
|
||||
ERROR: 'system:error',
|
||||
REFRESH_REQUESTED: 'system:refresh',
|
||||
OFFLINE_MODE_CHANGED: 'system:offline-mode-changed',
|
||||
|
||||
// Sync events (4)
|
||||
SYNC_STARTED: 'sync:started',
|
||||
SYNC_COMPLETED: 'sync:completed',
|
||||
SYNC_FAILED: 'sync:failed',
|
||||
SYNC_RETRY: 'sync:retry',
|
||||
|
||||
// Filter events (1)
|
||||
FILTER_CHANGED: 'filter:changed',
|
||||
|
|
|
|||
51
src/index.ts
51
src/index.ts
|
|
@ -21,9 +21,16 @@ import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
|||
import { DragHoverManager } from './managers/DragHoverManager';
|
||||
import { HeaderManager } from './managers/HeaderManager';
|
||||
|
||||
// Import repositories
|
||||
// Import repositories and storage
|
||||
import { IEventRepository } from './repositories/IEventRepository';
|
||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||
import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository';
|
||||
import { ApiEventRepository } from './repositories/ApiEventRepository';
|
||||
import { IndexedDBService } from './storage/IndexedDBService';
|
||||
import { OperationQueue } from './storage/OperationQueue';
|
||||
|
||||
// Import workers
|
||||
import { SyncManager } from './workers/SyncManager';
|
||||
|
||||
// Import renderers
|
||||
import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer';
|
||||
|
|
@ -53,8 +60,8 @@ async function handleDeepLinking(eventManager: EventManager, urlManager: URLMana
|
|||
console.log(`Deep linking to event ID: ${eventId}`);
|
||||
|
||||
// Wait a bit for managers to be fully ready
|
||||
setTimeout(() => {
|
||||
const success = eventManager.navigateToEvent(eventId);
|
||||
setTimeout(async () => {
|
||||
const success = await eventManager.navigateToEvent(eventId);
|
||||
if (!success) {
|
||||
console.warn(`Deep linking failed: Event with ID ${eventId} not found`);
|
||||
}
|
||||
|
|
@ -73,6 +80,22 @@ async function initializeCalendar(): Promise<void> {
|
|||
// Load configuration from JSON
|
||||
const config = await ConfigManager.load();
|
||||
|
||||
// ========================================
|
||||
// Initialize IndexedDB and seed if needed
|
||||
// ========================================
|
||||
const indexedDB = new IndexedDBService();
|
||||
await indexedDB.initialize();
|
||||
await indexedDB.seedIfEmpty();
|
||||
|
||||
// Create operation queue
|
||||
const queue = new OperationQueue(indexedDB);
|
||||
|
||||
// Create API repository (placeholder for now)
|
||||
const apiRepository = new ApiEventRepository(config.apiEndpoint || '/api');
|
||||
|
||||
// Create IndexedDB repository
|
||||
const repository = new IndexedDBEventRepository(indexedDB, queue);
|
||||
|
||||
// Create NovaDI container
|
||||
const container = new Container();
|
||||
const builder = container.builder();
|
||||
|
|
@ -86,8 +109,13 @@ async function initializeCalendar(): Promise<void> {
|
|||
// Register configuration instance
|
||||
builder.registerInstance(config).as<Configuration>();
|
||||
|
||||
// Register repositories
|
||||
builder.registerType(MockEventRepository).as<IEventRepository>();
|
||||
// Register IndexedDB and storage instances
|
||||
builder.registerInstance(indexedDB).as<IndexedDBService>();
|
||||
builder.registerInstance(queue).as<OperationQueue>();
|
||||
builder.registerInstance(apiRepository).as<ApiEventRepository>();
|
||||
|
||||
// Register repository
|
||||
builder.registerInstance(repository).as<IEventRepository>();
|
||||
|
||||
// Register renderers
|
||||
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
||||
|
|
@ -143,6 +171,13 @@ async function initializeCalendar(): Promise<void> {
|
|||
await calendarManager.initialize?.();
|
||||
await resizeHandleManager.initialize?.();
|
||||
|
||||
// ========================================
|
||||
// Initialize and start SyncManager
|
||||
// ========================================
|
||||
const syncManager = new SyncManager(eventBus, queue, indexedDB, apiRepository);
|
||||
syncManager.startSync();
|
||||
console.log('SyncManager initialized and started');
|
||||
|
||||
// Handle deep linking after managers are initialized
|
||||
await handleDeepLinking(eventManager, urlManager);
|
||||
|
||||
|
|
@ -153,12 +188,18 @@ async function initializeCalendar(): Promise<void> {
|
|||
app: typeof app;
|
||||
calendarManager: typeof calendarManager;
|
||||
eventManager: typeof eventManager;
|
||||
syncManager: typeof syncManager;
|
||||
indexedDB: typeof indexedDB;
|
||||
queue: typeof queue;
|
||||
};
|
||||
}).calendarDebug = {
|
||||
eventBus,
|
||||
app,
|
||||
calendarManager,
|
||||
eventManager,
|
||||
syncManager,
|
||||
indexedDB,
|
||||
queue,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -127,13 +127,13 @@ export class AllDayManager {
|
|||
});
|
||||
|
||||
// Listen for header ready - when dates are populated with period data
|
||||
eventBus.on('header:ready', (event: Event) => {
|
||||
eventBus.on('header:ready', async (event: Event) => {
|
||||
let headerReadyEventPayload = (event as CustomEvent<IHeaderReadyEventPayload>).detail;
|
||||
|
||||
let startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.date);
|
||||
let endDate = new Date(headerReadyEventPayload.headerElements.at(-1)!.date);
|
||||
|
||||
let events: ICalendarEvent[] = this.eventManager.getEventsForPeriod(startDate, endDate);
|
||||
let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate);
|
||||
// Filter for all-day events
|
||||
const allDayEvents = events.filter(event => event.allDay);
|
||||
|
||||
|
|
@ -380,7 +380,7 @@ export class AllDayManager {
|
|||
}
|
||||
|
||||
|
||||
private handleDragEnd(dragEndEvent: IDragEndEventPayload): void {
|
||||
private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise<void> {
|
||||
|
||||
const getEventDurationDays = (start: string | undefined, end: string | undefined): number => {
|
||||
|
||||
|
|
@ -496,6 +496,15 @@ export class AllDayManager {
|
|||
// 7. Apply highlight class to show the dropped event with highlight color
|
||||
dragEndEvent.draggedClone.classList.add('highlight');
|
||||
|
||||
// 8. CRITICAL FIX: Update event in repository to mark as allDay=true
|
||||
// This ensures EventManager's repository has correct state
|
||||
// Without this, the event will reappear in grid on re-render
|
||||
await this.eventManager.updateEvent(eventId, {
|
||||
start: newStartDate,
|
||||
end: newEndDate,
|
||||
allDay: true
|
||||
});
|
||||
|
||||
this.fadeOutAndRemove(dragEndEvent.originalElement);
|
||||
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ export class CalendarManager {
|
|||
/**
|
||||
* Re-render events after grid structure changes
|
||||
*/
|
||||
private rerenderEvents(): void {
|
||||
private async rerenderEvents(): Promise<void> {
|
||||
|
||||
// Get current period data to determine date range
|
||||
const periodData = this.calculateCurrentPeriod();
|
||||
|
|
@ -223,7 +223,7 @@ export class CalendarManager {
|
|||
}
|
||||
|
||||
// Trigger event rendering for the current date range using correct method
|
||||
this.eventRenderer.renderEvents({
|
||||
await this.eventRenderer.renderEvents({
|
||||
container: container as HTMLElement,
|
||||
startDate: new Date(periodData.start),
|
||||
endDate: new Date(periodData.end)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import { IEventRepository } from '../repositories/IEventRepository';
|
|||
|
||||
/**
|
||||
* EventManager - Event lifecycle and CRUD operations
|
||||
* Handles event management and CRUD operations
|
||||
* Delegates all data operations to IEventRepository
|
||||
* No longer maintains in-memory cache - repository is single source of truth
|
||||
*/
|
||||
export class EventManager {
|
||||
|
||||
private events: ICalendarEvent[] = [];
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private repository: IEventRepository;
|
||||
|
|
@ -28,30 +28,32 @@ export class EventManager {
|
|||
|
||||
/**
|
||||
* Load event data from repository
|
||||
* No longer caches - delegates to repository
|
||||
*/
|
||||
public async loadData(): Promise<void> {
|
||||
try {
|
||||
this.events = await this.repository.loadEvents();
|
||||
// Just ensure repository is ready - no caching
|
||||
await this.repository.loadEvents();
|
||||
} catch (error) {
|
||||
console.error('Failed to load event data:', error);
|
||||
this.events = [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events with optional copying for performance
|
||||
* Get all events from repository
|
||||
*/
|
||||
public getEvents(copy: boolean = false): ICalendarEvent[] {
|
||||
return copy ? [...this.events] : this.events;
|
||||
public async getEvents(copy: boolean = false): Promise<ICalendarEvent[]> {
|
||||
const events = await this.repository.loadEvents();
|
||||
return copy ? [...events] : events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized event lookup with early return
|
||||
* Get event by ID from repository
|
||||
*/
|
||||
public getEventById(id: string): ICalendarEvent | undefined {
|
||||
// Use find for better performance than filter + first
|
||||
return this.events.find(event => event.id === id);
|
||||
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
|
||||
const events = await this.repository.loadEvents();
|
||||
return events.find(event => event.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,8 +61,8 @@ export class EventManager {
|
|||
* @param id Event ID to find
|
||||
* @returns Event with navigation info or null if not found
|
||||
*/
|
||||
public getEventForNavigation(id: string): { event: ICalendarEvent; eventDate: Date } | null {
|
||||
const event = this.getEventById(id);
|
||||
public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> {
|
||||
const event = await this.getEventById(id);
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -90,8 +92,8 @@ export class EventManager {
|
|||
* @param eventId Event ID to navigate to
|
||||
* @returns true if event found and navigation initiated, false otherwise
|
||||
*/
|
||||
public navigateToEvent(eventId: string): boolean {
|
||||
const eventInfo = this.getEventForNavigation(eventId);
|
||||
public async navigateToEvent(eventId: string): Promise<boolean> {
|
||||
const eventInfo = await this.getEventForNavigation(eventId);
|
||||
if (!eventInfo) {
|
||||
console.warn(`EventManager: Event with ID ${eventId} not found`);
|
||||
return false;
|
||||
|
|
@ -113,23 +115,20 @@ export class EventManager {
|
|||
/**
|
||||
* Get events that overlap with a given time period
|
||||
*/
|
||||
public getEventsForPeriod(startDate: Date, endDate: Date): ICalendarEvent[] {
|
||||
public async getEventsForPeriod(startDate: Date, endDate: Date): Promise<ICalendarEvent[]> {
|
||||
const events = await this.repository.loadEvents();
|
||||
// Event overlaps period if it starts before period ends AND ends after period starts
|
||||
return this.events.filter(event => {
|
||||
return events.filter(event => {
|
||||
return event.start <= endDate && event.end >= startDate;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event and add it to the calendar
|
||||
* Delegates to repository with source='local'
|
||||
*/
|
||||
public addEvent(event: Omit<ICalendarEvent, 'id'>): ICalendarEvent {
|
||||
const newEvent: ICalendarEvent = {
|
||||
...event,
|
||||
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
};
|
||||
|
||||
this.events.push(newEvent);
|
||||
public async addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
|
||||
const newEvent = await this.repository.createEvent(event, 'local');
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
|
||||
event: newEvent
|
||||
|
|
@ -140,18 +139,59 @@ export class EventManager {
|
|||
|
||||
/**
|
||||
* Update an existing event
|
||||
* Delegates to repository with source='local'
|
||||
*/
|
||||
public updateEvent(id: string, updates: Partial<ICalendarEvent>): ICalendarEvent | null {
|
||||
const eventIndex = this.events.findIndex(event => event.id === id);
|
||||
if (eventIndex === -1) return null;
|
||||
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
|
||||
try {
|
||||
const updatedEvent = await this.repository.updateEvent(id, updates, 'local');
|
||||
|
||||
const updatedEvent = { ...this.events[eventIndex], ...updates };
|
||||
this.events[eventIndex] = updatedEvent;
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||
event: updatedEvent
|
||||
});
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||
event: updatedEvent
|
||||
});
|
||||
return updatedEvent;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update event ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedEvent;
|
||||
/**
|
||||
* Delete an event
|
||||
* Delegates to repository with source='local'
|
||||
*/
|
||||
public async deleteEvent(id: string): Promise<boolean> {
|
||||
try {
|
||||
await this.repository.deleteEvent(id, 'local');
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
|
||||
eventId: id
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete event ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle remote update from SignalR
|
||||
* Delegates to repository with source='remote'
|
||||
*/
|
||||
public async handleRemoteUpdate(event: ICalendarEvent): Promise<void> {
|
||||
try {
|
||||
await this.repository.updateEvent(event.id, event, 'remote');
|
||||
|
||||
this.eventBus.emit(CoreEvents.REMOTE_UPDATE_RECEIVED, {
|
||||
event
|
||||
});
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||
event
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle remote update for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,12 +36,12 @@ export class EventRenderingService {
|
|||
/**
|
||||
* Render events in a specific container for a given period
|
||||
*/
|
||||
public renderEvents(context: IRenderContext): void {
|
||||
public async renderEvents(context: IRenderContext): Promise<void> {
|
||||
// Clear existing events in the specific container first
|
||||
this.strategy.clearEvents(context.container);
|
||||
|
||||
// Get events from EventManager for the period
|
||||
const events = this.eventManager.getEventsForPeriod(
|
||||
const events = await this.eventManager.getEventsForPeriod(
|
||||
context.startDate,
|
||||
context.endDate
|
||||
);
|
||||
|
|
@ -159,7 +159,7 @@ export class EventRenderingService {
|
|||
}
|
||||
|
||||
private setupDragEndListener(): void {
|
||||
this.eventBus.on('drag:end', (event: Event) => {
|
||||
this.eventBus.on('drag:end', async (event: Event) => {
|
||||
|
||||
const { originalElement: draggedElement, sourceColumn, finalPosition, target } = (event as CustomEvent<IDragEndEventPayload>).detail;
|
||||
const finalColumn = finalPosition.column;
|
||||
|
|
@ -181,7 +181,7 @@ export class EventRenderingService {
|
|||
const newStart = swpEvent.start;
|
||||
const newEnd = swpEvent.end;
|
||||
|
||||
this.eventManager.updateEvent(eventId, {
|
||||
await this.eventManager.updateEvent(eventId, {
|
||||
start: newStart,
|
||||
end: newEnd
|
||||
});
|
||||
|
|
@ -262,7 +262,7 @@ export class EventRenderingService {
|
|||
}
|
||||
|
||||
private setupResizeEndListener(): void {
|
||||
this.eventBus.on('resize:end', (event: Event) => {
|
||||
this.eventBus.on('resize:end', async (event: Event) => {
|
||||
const { eventId, element } = (event as CustomEvent<IResizeEndEventPayload>).detail;
|
||||
|
||||
// Update event data in EventManager with new end time from resized element
|
||||
|
|
@ -270,7 +270,7 @@ export class EventRenderingService {
|
|||
const newStart = swpEvent.start;
|
||||
const newEnd = swpEvent.end;
|
||||
|
||||
this.eventManager.updateEvent(eventId, {
|
||||
await this.eventManager.updateEvent(eventId, {
|
||||
start: newStart,
|
||||
end: newEnd
|
||||
});
|
||||
|
|
|
|||
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';
|
||||
|
||||
/**
|
||||
* IEventRepository - Interface for event data loading
|
||||
* Update source type
|
||||
* - 'local': Changes made by the user locally (needs sync)
|
||||
* - 'remote': Changes from API/SignalR (already synced)
|
||||
*/
|
||||
export type UpdateSource = 'local' | 'remote';
|
||||
|
||||
/**
|
||||
* IEventRepository - Interface for event data access
|
||||
*
|
||||
* Abstracts the data source for calendar events, allowing easy switching
|
||||
* between mock data, REST API, GraphQL, or other data sources.
|
||||
* between IndexedDB, REST API, GraphQL, or other data sources.
|
||||
*
|
||||
* Implementations:
|
||||
* - MockEventRepository: Loads from local JSON file
|
||||
* - IndexedDBEventRepository: Local storage with offline support
|
||||
* - MockEventRepository: (Legacy) Loads from local JSON file
|
||||
* - ApiEventRepository: (Future) Loads from backend API
|
||||
*/
|
||||
export interface IEventRepository {
|
||||
|
|
@ -17,4 +25,32 @@ export interface IEventRepository {
|
|||
* @throws Error if loading fails
|
||||
*/
|
||||
loadEvents(): Promise<ICalendarEvent[]>;
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
* @param event - Event to create (without ID, will be generated)
|
||||
* @param source - Source of the update ('local' or 'remote')
|
||||
* @returns Promise resolving to the created event with generated ID
|
||||
* @throws Error if creation fails
|
||||
*/
|
||||
createEvent(event: Omit<ICalendarEvent, 'id'>, source?: UpdateSource): Promise<ICalendarEvent>;
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
* @param id - ID of the event to update
|
||||
* @param updates - Partial event data to update
|
||||
* @param source - Source of the update ('local' or 'remote')
|
||||
* @returns Promise resolving to the updated event
|
||||
* @throws Error if update fails or event not found
|
||||
*/
|
||||
updateEvent(id: string, updates: Partial<ICalendarEvent>, source?: UpdateSource): Promise<ICalendarEvent>;
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
* @param id - ID of the event to delete
|
||||
* @param source - Source of the update ('local' or 'remote')
|
||||
* @returns Promise resolving when deletion is complete
|
||||
* @throws Error if deletion fails or event not found
|
||||
*/
|
||||
deleteEvent(id: string, source?: UpdateSource): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
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 { IEventRepository } from './IEventRepository';
|
||||
import { IEventRepository, UpdateSource } from './IEventRepository';
|
||||
|
||||
interface RawEventData {
|
||||
id: string;
|
||||
|
|
@ -13,12 +13,15 @@ interface RawEventData {
|
|||
}
|
||||
|
||||
/**
|
||||
* MockEventRepository - Loads event data from local JSON file
|
||||
* MockEventRepository - Loads event data from local JSON file (LEGACY)
|
||||
*
|
||||
* This repository implementation fetches mock event data from a static JSON file.
|
||||
* Used for development and testing before backend API is available.
|
||||
* DEPRECATED: Use IndexedDBEventRepository for offline-first functionality.
|
||||
*
|
||||
* Data Source: data/mock-events.json
|
||||
*
|
||||
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||
* This is intentional to encourage migration to IndexedDBEventRepository.
|
||||
*/
|
||||
export class MockEventRepository implements IEventRepository {
|
||||
private readonly dataUrl = 'data/mock-events.json';
|
||||
|
|
@ -40,6 +43,30 @@ export class MockEventRepository implements IEventRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockEventRepository is read-only
|
||||
* Use IndexedDBEventRepository instead
|
||||
*/
|
||||
public async createEvent(event: Omit<ICalendarEvent, 'id'>, source?: UpdateSource): Promise<ICalendarEvent> {
|
||||
throw new Error('MockEventRepository does not support createEvent. Use IndexedDBEventRepository instead.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockEventRepository is read-only
|
||||
* Use IndexedDBEventRepository instead
|
||||
*/
|
||||
public async updateEvent(id: string, updates: Partial<ICalendarEvent>, source?: UpdateSource): Promise<ICalendarEvent> {
|
||||
throw new Error('MockEventRepository does not support updateEvent. Use IndexedDBEventRepository instead.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockEventRepository is read-only
|
||||
* Use IndexedDBEventRepository instead
|
||||
*/
|
||||
public async deleteEvent(id: string, source?: UpdateSource): Promise<void> {
|
||||
throw new Error('MockEventRepository does not support deleteEvent. Use IndexedDBEventRepository instead.');
|
||||
}
|
||||
|
||||
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
||||
return data.map((event): ICalendarEvent => ({
|
||||
...event,
|
||||
|
|
|
|||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue