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
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue