Refactor entity services with hybrid sync pattern

Introduces BaseEntityService and SyncPlugin to eliminate code duplication across entity services

Improves:
- Code reusability through inheritance and composition
- Sync infrastructure for all entity types
- Polymorphic sync status management
- Reduced boilerplate code by ~75%

Supports generic sync for Event, Booking, Customer, and Resource entities
This commit is contained in:
Janus C. H. Knudsen 2025-11-18 16:37:33 +01:00
parent 2aa9d06fab
commit 8e52d670d6
30 changed files with 1960 additions and 526 deletions

View file

@ -1,4 +1,5 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
import { Configuration } from '../configurations/CalendarConfig';
import { TimeFormatter } from '../utils/TimeFormatter';
import { PositionUtils } from '../utils/PositionUtils';
@ -307,7 +308,7 @@ export class SwpEventElement extends BaseSwpEventElement {
description: element.dataset.description || undefined,
start: new Date(element.dataset.start || ''),
end: new Date(element.dataset.end || ''),
type: element.dataset.type || 'work',
type: element.dataset.type as CalendarEventType,
allDay: false,
syncStatus: 'synced',
metadata: {

View file

@ -26,7 +26,11 @@ import { WorkweekPresets } from './components/WorkweekPresets';
import { IEventRepository } from './repositories/IEventRepository';
import { MockEventRepository } from './repositories/MockEventRepository';
import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository';
import { IApiRepository } from './repositories/IApiRepository';
import { ApiEventRepository } from './repositories/ApiEventRepository';
import { ApiBookingRepository } from './repositories/ApiBookingRepository';
import { ApiCustomerRepository } from './repositories/ApiCustomerRepository';
import { ApiResourceRepository } from './repositories/ApiResourceRepository';
import { IndexedDBService } from './storage/IndexedDBService';
import { OperationQueue } from './storage/OperationQueue';
import { IStore } from './storage/IStore';
@ -34,6 +38,11 @@ import { BookingStore } from './storage/bookings/BookingStore';
import { CustomerStore } from './storage/customers/CustomerStore';
import { ResourceStore } from './storage/resources/ResourceStore';
import { EventStore } from './storage/events/EventStore';
import { IEntityService } from './storage/IEntityService';
import { EventService } from './storage/events/EventService';
import { BookingService } from './storage/bookings/BookingService';
import { CustomerService } from './storage/customers/CustomerService';
import { ResourceService } from './storage/resources/ResourceService';
// Import workers
import { SyncManager } from './workers/SyncManager';
@ -113,7 +122,30 @@ async function initializeCalendar(): Promise<void> {
// Register storage and repository services
builder.registerType(IndexedDBService).as<IndexedDBService>();
builder.registerType(OperationQueue).as<OperationQueue>();
builder.registerType(ApiEventRepository).as<ApiEventRepository>();
// Register API repositories (backend sync)
// Each entity type has its own API repository implementing IApiRepository<T>
builder.registerType(ApiEventRepository).as<IApiRepository<any>>();
builder.registerType(ApiBookingRepository).as<IApiRepository<any>>();
builder.registerType(ApiCustomerRepository).as<IApiRepository<any>>();
builder.registerType(ApiResourceRepository).as<IApiRepository<any>>();
// Resolve all API repositories and register as array for SyncManager
const apiRepositories = container.resolveTypeAll<IApiRepository<any>>();
builder.registerInstance(apiRepositories).as<IApiRepository<any>[]>();
// Register entity services (sync status management)
// Open/Closed Principle: Adding new entity only requires adding one line here
builder.registerType(EventService).as<IEntityService<any>>();
builder.registerType(BookingService).as<IEntityService<any>>();
builder.registerType(CustomerService).as<IEntityService<any>>();
builder.registerType(ResourceService).as<IEntityService<any>>();
// Resolve all IEntityService implementations and register as array for SyncManager
const entityServices = container.resolveTypeAll<IEntityService<any>>();
builder.registerInstance(entityServices).as<IEntityService<any>[]>();
// Register IndexedDB repositories (offline-first)
builder.registerType(IndexedDBEventRepository).as<IEventRepository>();
// Register workers

View file

@ -6,6 +6,7 @@ import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine';
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
import {
IDragMouseEnterHeaderEventPayload,
@ -164,8 +165,8 @@ export class AllDayManager {
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 startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.data as Date);
let endDate = new Date(headerReadyEventPayload.headerElements.at(-1)!.data as Date);
let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate);
// Filter for all-day events
@ -397,7 +398,7 @@ export class AllDayManager {
this.currentWeekDates = dayHeaders;
// Initialize layout engine with provided week dates
let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.date));
let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.data as Date));
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
return layoutEngine.calculateLayout(events);
@ -485,10 +486,10 @@ export class AllDayManager {
*/
private async handleTimedToAllDayDrop(dragEndEvent: IDragEndEventPayload): Promise<void> {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = clone.eventId.replace('clone-', '');
const targetDate = dragEndEvent.finalPosition.column.date;
const targetDate = dragEndEvent.finalPosition.column.data as Date;
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate });
@ -515,7 +516,7 @@ export class AllDayManager {
title: clone.title,
start: newStart,
end: newEnd,
type: clone.type,
type: clone.type as CalendarEventType,
allDay: true,
syncStatus: 'synced'
};
@ -533,10 +534,10 @@ export class AllDayManager {
*/
private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise<void> {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = clone.eventId.replace('clone-', '');
const targetDate = dragEndEvent.finalPosition.column.date;
const targetDate = dragEndEvent.finalPosition.column.data as Date;
// Calculate duration in days
const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start);

View file

@ -102,7 +102,7 @@ export class DateEventRenderer implements IEventRenderer {
public handleDragMove(payload: IDragMoveEventPayload): void {
const swpEvent = payload.draggedClone as SwpEventElement;
const columnDate = this.dateService.parseISO(payload.columnBounds!!.date);
const columnDate = this.dateService.parseISO(payload.columnBounds!!.data as Date);
swpEvent.updatePosition(columnDate, payload.snappedY);
}
@ -118,7 +118,7 @@ export class DateEventRenderer implements IEventRenderer {
// Recalculate timestamps with new column date
const currentTop = parseFloat(payload.draggedClone.style.top) || 0;
const swpEvent = payload.draggedClone as SwpEventElement;
const columnDate = this.dateService.parseISO(payload.newColumn.date);
const columnDate = this.dateService.parseISO(payload.newColumn.data as Date);
swpEvent.updatePosition(columnDate, currentTop);
}
}
@ -130,7 +130,7 @@ export class DateEventRenderer implements IEventRenderer {
console.log('🎯 DateEventRenderer: Converting all-day to timed event', {
eventId: payload.calendarEvent.id,
targetColumn: payload.targetColumn.date,
targetColumn: payload.targetColumn.data as Date,
snappedY: payload.snappedY
});

View file

@ -210,7 +210,7 @@ export class EventRenderingService {
private setupDragMouseLeaveHeaderListener(): void {
this.dragMouseLeaveHeaderListener = (event: Event) => {
const { targetDate, mousePosition, originalElement, draggedClone: cloneElement } = (event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail;
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } = (event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail;
if (cloneElement)
cloneElement.style.display = '';
@ -268,7 +268,8 @@ export class EventRenderingService {
newEnd
});
let columnBounds = ColumnDetectionUtils.getColumnBoundsByDate(newStart);
const dateIdentifier = newStart.toISOString().split('T')[0];
let columnBounds = ColumnDetectionUtils.getColumnBoundsByIdentifier(dateIdentifier);
if (columnBounds)
await this.renderSingleColumn(columnBounds);
@ -295,7 +296,7 @@ export class EventRenderingService {
}
// Re-render target column if exists and different from source
if (targetColumn && targetColumn.date !== originalSourceColumn?.date) {
if (targetColumn && (targetColumn.data as Date) !== (originalSourceColumn?.data as Date)) {
await this.renderSingleColumn(targetColumn);
}
}
@ -316,8 +317,9 @@ export class EventRenderingService {
*/
private async renderSingleColumn(column: IColumnBounds): Promise<void> {
// Get events for just this column's date
const columnStart = this.dateService.parseISO(`${column.date}T00:00:00`);
const columnEnd = this.dateService.parseISO(`${column.date}T23:59:59.999`);
const dateString = (column.data as Date).toISOString().split('T')[0];
const columnStart = this.dateService.parseISO(`${dateString}T00:00:00`);
const columnEnd = this.dateService.parseISO(`${dateString}T23:59:59.999`);
// Get events from EventManager for this single date
const events = await this.eventManager.getEventsForPeriod(columnStart, columnEnd);
@ -341,7 +343,7 @@ export class EventRenderingService {
}
console.log('🔄 EventRendererManager: Re-rendered single column', {
columnDate: column.date,
columnDate: column.data as Date,
eventsCount: timedEvents.length
});
}

View file

@ -0,0 +1,92 @@
import { IBooking } from '../types/BookingTypes';
import { EntityType } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
import { IApiRepository } from './IApiRepository';
/**
* ApiBookingRepository
* Handles communication with backend API for bookings
*
* Implements IApiRepository<IBooking> for generic sync infrastructure.
* Used by SyncManager to send queued booking operations to the server.
*/
export class ApiBookingRepository implements IApiRepository<IBooking> {
readonly entityType: EntityType = 'Booking';
private apiEndpoint: string;
constructor(config: Configuration) {
this.apiEndpoint = config.apiEndpoint;
}
/**
* Send create operation to API
*/
async sendCreate(booking: IBooking): Promise<IBooking> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/bookings`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(booking)
// });
//
// if (!response.ok) {
// throw new Error(`API create failed: ${response.statusText}`);
// }
//
// return await response.json();
throw new Error('ApiBookingRepository.sendCreate not implemented yet');
}
/**
* Send update operation to API
*/
async sendUpdate(id: string, updates: Partial<IBooking>): Promise<IBooking> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/bookings/${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('ApiBookingRepository.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}/bookings/${id}`, {
// method: 'DELETE'
// });
//
// if (!response.ok) {
// throw new Error(`API delete failed: ${response.statusText}`);
// }
throw new Error('ApiBookingRepository.sendDelete not implemented yet');
}
/**
* Fetch all bookings from API
*/
async fetchAll(): Promise<IBooking[]> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/bookings`);
//
// if (!response.ok) {
// throw new Error(`API fetch failed: ${response.statusText}`);
// }
//
// return await response.json();
throw new Error('ApiBookingRepository.fetchAll not implemented yet');
}
}

View file

@ -0,0 +1,92 @@
import { ICustomer } from '../types/CustomerTypes';
import { EntityType } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
import { IApiRepository } from './IApiRepository';
/**
* ApiCustomerRepository
* Handles communication with backend API for customers
*
* Implements IApiRepository<ICustomer> for generic sync infrastructure.
* Used by SyncManager to send queued customer operations to the server.
*/
export class ApiCustomerRepository implements IApiRepository<ICustomer> {
readonly entityType: EntityType = 'Customer';
private apiEndpoint: string;
constructor(config: Configuration) {
this.apiEndpoint = config.apiEndpoint;
}
/**
* Send create operation to API
*/
async sendCreate(customer: ICustomer): Promise<ICustomer> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/customers`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(customer)
// });
//
// if (!response.ok) {
// throw new Error(`API create failed: ${response.statusText}`);
// }
//
// return await response.json();
throw new Error('ApiCustomerRepository.sendCreate not implemented yet');
}
/**
* Send update operation to API
*/
async sendUpdate(id: string, updates: Partial<ICustomer>): Promise<ICustomer> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/customers/${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('ApiCustomerRepository.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}/customers/${id}`, {
// method: 'DELETE'
// });
//
// if (!response.ok) {
// throw new Error(`API delete failed: ${response.statusText}`);
// }
throw new Error('ApiCustomerRepository.sendDelete not implemented yet');
}
/**
* Fetch all customers from API
*/
async fetchAll(): Promise<ICustomer[]> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/customers`);
//
// if (!response.ok) {
// throw new Error(`API fetch failed: ${response.statusText}`);
// }
//
// return await response.json();
throw new Error('ApiCustomerRepository.fetchAll not implemented yet');
}
}

View file

@ -1,19 +1,22 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
import { IApiRepository } from './IApiRepository';
/**
* ApiEventRepository
* Handles communication with backend API
* Handles communication with backend API for calendar events
*
* Used by SyncManager to send queued operations to the server
* NOT used directly by EventManager (which uses IndexedDBEventRepository)
* Implements IApiRepository<ICalendarEvent> for generic sync infrastructure.
* 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 {
export class ApiEventRepository implements IApiRepository<ICalendarEvent> {
readonly entityType: EntityType = 'Event';
private apiEndpoint: string;
constructor(config: Configuration) {

View file

@ -0,0 +1,92 @@
import { IResource } from '../types/ResourceTypes';
import { EntityType } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
import { IApiRepository } from './IApiRepository';
/**
* ApiResourceRepository
* Handles communication with backend API for resources
*
* Implements IApiRepository<IResource> for generic sync infrastructure.
* Used by SyncManager to send queued resource operations to the server.
*/
export class ApiResourceRepository implements IApiRepository<IResource> {
readonly entityType: EntityType = 'Resource';
private apiEndpoint: string;
constructor(config: Configuration) {
this.apiEndpoint = config.apiEndpoint;
}
/**
* Send create operation to API
*/
async sendCreate(resource: IResource): Promise<IResource> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/resources`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(resource)
// });
//
// if (!response.ok) {
// throw new Error(`API create failed: ${response.statusText}`);
// }
//
// return await response.json();
throw new Error('ApiResourceRepository.sendCreate not implemented yet');
}
/**
* Send update operation to API
*/
async sendUpdate(id: string, updates: Partial<IResource>): Promise<IResource> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/resources/${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('ApiResourceRepository.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}/resources/${id}`, {
// method: 'DELETE'
// });
//
// if (!response.ok) {
// throw new Error(`API delete failed: ${response.statusText}`);
// }
throw new Error('ApiResourceRepository.sendDelete not implemented yet');
}
/**
* Fetch all resources from API
*/
async fetchAll(): Promise<IResource[]> {
// TODO: Implement API call
// const response = await fetch(`${this.apiEndpoint}/resources`);
//
// if (!response.ok) {
// throw new Error(`API fetch failed: ${response.statusText}`);
// }
//
// return await response.json();
throw new Error('ApiResourceRepository.fetchAll not implemented yet');
}
}

View file

@ -0,0 +1,60 @@
import { EntityType } from '../types/CalendarTypes';
/**
* IApiRepository<T> - Generic interface for backend API communication
*
* All entity-specific API repositories (Event, Booking, Customer, Resource)
* must implement this interface to ensure consistent sync behavior.
*
* Used by SyncManager to route operations to the correct API endpoints
* based on entity type (dataEntity.typename).
*
* Pattern:
* - Each entity has its own concrete implementation (ApiEventRepository, ApiBookingRepository, etc.)
* - SyncManager maintains a map of entityType IApiRepository<T>
* - Operations are routed at runtime based on IQueueOperation.dataEntity.typename
*/
export interface IApiRepository<T> {
/**
* Entity type discriminator - used for runtime routing
* Must match EntityType values ('Event', 'Booking', 'Customer', 'Resource')
*/
readonly entityType: EntityType;
/**
* Send create operation to backend API
*
* @param data - Entity data to create
* @returns Promise<T> - Created entity from server (with server-generated fields)
* @throws Error if API call fails
*/
sendCreate(data: T): Promise<T>;
/**
* Send update operation to backend API
*
* @param id - Entity ID
* @param updates - Partial entity data to update
* @returns Promise<T> - Updated entity from server
* @throws Error if API call fails
*/
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
/**
* Send delete operation to backend API
*
* @param id - Entity ID to delete
* @returns Promise<void>
* @throws Error if API call fails
*/
sendDelete(id: string): Promise<void>;
/**
* Fetch all entities from backend API
* Used for initial sync and full refresh
*
* @returns Promise<T[]> - Array of all entities
* @throws Error if API call fails
*/
fetchAll(): Promise<T[]>;
}

View file

@ -1,6 +1,7 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { IEventRepository, UpdateSource } from './IEventRepository';
import { IndexedDBService } from '../storage/IndexedDBService';
import { EventService } from '../storage/events/EventService';
import { OperationQueue } from '../storage/OperationQueue';
/**
@ -8,31 +9,45 @@ import { OperationQueue } from '../storage/OperationQueue';
* Offline-first repository using IndexedDB as single source of truth
*
* All CRUD operations:
* - Save to IndexedDB immediately (always succeeds)
* - Save to IndexedDB immediately via EventService (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 eventService: EventService;
private queue: OperationQueue;
constructor(indexedDB: IndexedDBService, queue: OperationQueue) {
this.indexedDB = indexedDB;
this.queue = queue;
// EventService will be initialized after IndexedDB is ready
this.eventService = null as any;
}
/**
* Ensure EventService is initialized with database connection
*/
private ensureEventService(): void {
if (!this.eventService && this.indexedDB.isInitialized()) {
const db = (this.indexedDB as any).db; // Access private db property
this.eventService = new EventService(db);
}
}
/**
* Load all events from IndexedDB
* Ensures IndexedDB is initialized and seeded on first call
* Ensures IndexedDB is initialized on first call
*/
async loadEvents(): Promise<ICalendarEvent[]> {
// Lazy initialization on first data load
if (!this.indexedDB.isInitialized()) {
await this.indexedDB.initialize();
await this.indexedDB.seedIfEmpty();
// TODO: Seeding should be done at application level, not here
}
return await this.indexedDB.getAllEvents();
this.ensureEventService();
return await this.eventService.getAll();
}
/**
@ -55,15 +70,19 @@ export class IndexedDBEventRepository implements IEventRepository {
syncStatus
} as ICalendarEvent;
// Save to IndexedDB
await this.indexedDB.saveEvent(newEvent);
// Save to IndexedDB via EventService
this.ensureEventService();
await this.eventService.save(newEvent);
// If local change, add to sync queue
if (source === 'local') {
await this.queue.enqueue({
type: 'create',
eventId: id,
data: newEvent,
entityId: id,
dataEntity: {
typename: 'Event',
data: newEvent
},
timestamp: Date.now(),
retryCount: 0
});
@ -78,8 +97,9 @@ export class IndexedDBEventRepository implements IEventRepository {
* - 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);
// Get existing event via EventService
this.ensureEventService();
const existingEvent = await this.eventService.get(id);
if (!existingEvent) {
throw new Error(`Event with ID ${id} not found`);
}
@ -95,15 +115,18 @@ export class IndexedDBEventRepository implements IEventRepository {
syncStatus
};
// Save to IndexedDB
await this.indexedDB.saveEvent(updatedEvent);
// Save to IndexedDB via EventService
await this.eventService.save(updatedEvent);
// If local change, add to sync queue
if (source === 'local') {
await this.queue.enqueue({
type: 'update',
eventId: id,
data: updates,
entityId: id,
dataEntity: {
typename: 'Event',
data: updates
},
timestamp: Date.now(),
retryCount: 0
});
@ -118,8 +141,9 @@ export class IndexedDBEventRepository implements IEventRepository {
* - 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);
// Check if event exists via EventService
this.ensureEventService();
const existingEvent = await this.eventService.get(id);
if (!existingEvent) {
throw new Error(`Event with ID ${id} not found`);
}
@ -129,15 +153,18 @@ export class IndexedDBEventRepository implements IEventRepository {
if (source === 'local') {
await this.queue.enqueue({
type: 'delete',
eventId: id,
data: {}, // No data needed for delete
entityId: id,
dataEntity: {
typename: 'Event',
data: { id } // Minimal data for delete - just ID
},
timestamp: Date.now(),
retryCount: 0
});
}
// Delete from IndexedDB
await this.indexedDB.deleteEvent(id);
// Delete from IndexedDB via EventService
await this.eventService.delete(id);
}
/**

View file

@ -1,4 +1,5 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
import { IEventRepository, UpdateSource } from './IEventRepository';
interface RawEventData {
@ -72,7 +73,7 @@ export class MockEventRepository implements IEventRepository {
...event,
start: new Date(event.start),
end: new Date(event.end),
type: event.type,
type: event.type as CalendarEventType,
allDay: event.allDay || false,
syncStatus: 'synced' as const
}));

View file

@ -0,0 +1,211 @@
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
*
* HYBRID PATTERN: Inheritance + Composition
* - Services EXTEND this base class (inheritance for structure)
* - Sync logic is COMPOSED via SyncPlugin (pluggable)
*
* PROVIDES:
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
*
* SUBCLASSES MUST IMPLEMENT:
* - storeName: string (IndexedDB object store name)
* - entityType: EntityType (for runtime routing)
*
* SUBCLASSES MAY OVERRIDE:
* - serialize(entity: T): any (default: no serialization)
* - deserialize(data: any): T (default: no deserialization)
*
* BENEFITS:
* - DRY: Single source of truth for CRUD logic
* - Type safety: Generic T ensures compile-time checking
* - Pluggable: SyncPlugin can be swapped for testing/different implementations
* - Open/Closed: New entities just extend this class
*/
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
// Abstract properties - must be implemented by subclasses
abstract readonly storeName: string;
abstract readonly entityType: EntityType;
// Internal composition - sync functionality
private syncPlugin: SyncPlugin<T>;
// Protected database instance - accessible to subclasses
protected db: IDBDatabase;
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
this.syncPlugin = new SyncPlugin<T>(this);
}
/**
* Serialize entity before storing in IndexedDB
* Override in subclass if entity has Date fields or needs transformation
*
* @param entity - Entity to serialize
* @returns Serialized data (default: entity itself)
*/
protected serialize(entity: T): any {
return entity; // Default: no serialization
}
/**
* Deserialize data from IndexedDB back to entity
* Override in subclass if entity has Date fields or needs transformation
*
* @param data - Raw data from IndexedDB
* @returns Deserialized entity (default: data itself)
*/
protected deserialize(data: any): T {
return data as T; // Default: no deserialization
}
/**
* Get a single entity by ID
*
* @param id - Entity ID
* @returns Entity or null if not found
*/
async get(id: string): Promise<T | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
resolve(this.deserialize(data));
} else {
resolve(null);
}
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`));
};
});
}
/**
* Get all entities
*
* @returns Array of all entities
*/
async getAll(): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as any[];
const entities = data.map(item => this.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get all ${this.entityType}s: ${request.error}`));
};
});
}
/**
* Save an entity (create or update)
*
* @param entity - Entity to save
*/
async save(entity: T): Promise<void> {
const serialized = this.serialize(entity);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save ${this.entityType} ${(entity as any).id}: ${request.error}`));
};
});
}
/**
* Delete an entity
*
* @param id - Entity ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`));
};
});
}
// ============================================================================
// SYNC METHODS (IEntityService implementation) - Delegates to SyncPlugin
// ============================================================================
/**
* Mark entity as successfully synced (IEntityService implementation)
* Delegates to SyncPlugin
*
* @param id - Entity ID
*/
async markAsSynced(id: string): Promise<void> {
return this.syncPlugin.markAsSynced(id);
}
/**
* Mark entity as sync error (IEntityService implementation)
* Delegates to SyncPlugin
*
* @param id - Entity ID
*/
async markAsError(id: string): Promise<void> {
return this.syncPlugin.markAsError(id);
}
/**
* Get sync status for an entity (IEntityService implementation)
* Delegates to SyncPlugin
*
* @param id - Entity ID
* @returns SyncStatus or null if entity not found
*/
async getSyncStatus(id: string): Promise<SyncStatus | null> {
return this.syncPlugin.getSyncStatus(id);
}
/**
* Get entities by sync status
* Delegates to SyncPlugin - uses IndexedDB syncStatus index
*
* @param syncStatus - Sync status ('synced', 'pending', 'error')
* @returns Array of entities with this sync status
*/
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return this.syncPlugin.getBySyncStatus(syncStatus);
}
}

View file

@ -0,0 +1,46 @@
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
/**
* IEntityService<T> - Generic interface for entity services with sync capabilities
*
* All entity services (Event, Booking, Customer, Resource) implement this interface
* to enable polymorphic sync status management in SyncManager.
*
* ENCAPSULATION: Services encapsulate sync status manipulation.
* SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service.
*
* POLYMORFI: SyncManager works with Array<IEntityService<any>> and uses
* entityType property for runtime routing, avoiding switch statements.
*/
export interface IEntityService<T extends ISync> {
/**
* Entity type discriminator for runtime routing
* Must match EntityType values: 'Event', 'Booking', 'Customer', 'Resource'
*/
readonly entityType: EntityType;
/**
* Mark entity as successfully synced with backend
* Sets syncStatus = 'synced' and persists to IndexedDB
*
* @param id - Entity ID
*/
markAsSynced(id: string): Promise<void>;
/**
* Mark entity as sync error (max retries exceeded)
* Sets syncStatus = 'error' and persists to IndexedDB
*
* @param id - Entity ID
*/
markAsError(id: string): Promise<void>;
/**
* Get current sync status for an entity
* Used by SyncManager to check entity state
*
* @param id - Entity ID
* @returns SyncStatus or null if entity not found
*/
getSyncStatus(id: string): Promise<SyncStatus | null>;
}

View file

@ -1,14 +1,15 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { IDataEntity } from '../types/CalendarTypes';
import { IStore } from './IStore';
/**
* Operation for the sync queue
* Generic structure supporting all entity types (Event, Booking, Customer, Resource)
*/
export interface IQueueOperation {
id: string;
type: 'create' | 'update' | 'delete';
eventId: string;
data: Partial<ICalendarEvent> | ICalendarEvent;
entityId: string;
dataEntity: IDataEntity;
timestamp: number;
retryCount: number;
}
@ -116,7 +117,7 @@ export class IndexedDBService {
const db = this.ensureDB();
const queueItem: IQueueOperation = {
...operation,
id: `${operation.type}-${operation.eventId}-${Date.now()}`
id: `${operation.type}-${operation.entityId}-${Date.now()}`
};
return new Promise((resolve, reject) => {

View file

@ -77,23 +77,37 @@ export class OperationQueue {
}
/**
* Get operations for a specific event ID
* Get operations for a specific entity ID
*/
async getOperationsForEvent(eventId: string): Promise<IQueueOperation[]> {
async getOperationsForEntity(entityId: string): Promise<IQueueOperation[]> {
const queue = await this.getAll();
return queue.filter(op => op.eventId === eventId);
return queue.filter(op => op.entityId === entityId);
}
/**
* Remove all operations for a specific event ID
* Remove all operations for a specific entity ID
*/
async removeOperationsForEvent(eventId: string): Promise<void> {
const operations = await this.getOperationsForEvent(eventId);
async removeOperationsForEntity(entityId: string): Promise<void> {
const operations = await this.getOperationsForEntity(entityId);
for (const op of operations) {
await this.remove(op.id);
}
}
/**
* @deprecated Use getOperationsForEntity instead
*/
async getOperationsForEvent(eventId: string): Promise<IQueueOperation[]> {
return this.getOperationsForEntity(eventId);
}
/**
* @deprecated Use removeOperationsForEntity instead
*/
async removeOperationsForEvent(eventId: string): Promise<void> {
return this.removeOperationsForEntity(eventId);
}
/**
* Update retry count for an operation
*/

90
src/storage/SyncPlugin.ts Normal file
View file

@ -0,0 +1,90 @@
import { ISync, SyncStatus, EntityType } from '../types/CalendarTypes';
/**
* SyncPlugin<T extends ISync> - Pluggable sync functionality for entity services
*
* COMPOSITION PATTERN:
* - Encapsulates all sync-related logic in separate class
* - Composed into BaseEntityService (not inheritance)
* - Allows sync functionality to be swapped/mocked for testing
* - Single Responsibility: Only handles sync status management
*
* DESIGN:
* - Takes reference to BaseEntityService for calling get/save
* - Implements sync methods that delegate to service's CRUD
* - Uses IndexedDB syncStatus index for efficient queries
*/
export class SyncPlugin<T extends ISync> {
/**
* @param service - Reference to BaseEntityService for CRUD operations
*/
constructor(private service: any) {
// Type is 'any' to avoid circular dependency at compile time
// Runtime: service is BaseEntityService<T>
}
/**
* Mark entity as successfully synced
* Sets syncStatus = 'synced' and persists to IndexedDB
*
* @param id - Entity ID
*/
async markAsSynced(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'synced';
await this.service.save(entity);
}
}
/**
* Mark entity as sync error (max retries exceeded)
* Sets syncStatus = 'error' and persists to IndexedDB
*
* @param id - Entity ID
*/
async markAsError(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'error';
await this.service.save(entity);
}
}
/**
* Get current sync status for an entity
*
* @param id - Entity ID
* @returns SyncStatus or null if entity not found
*/
async getSyncStatus(id: string): Promise<SyncStatus | null> {
const entity = await this.service.get(id);
return entity ? entity.syncStatus : null;
}
/**
* Get entities by sync status
* Uses IndexedDB syncStatus index for efficient querying
*
* @param syncStatus - Sync status ('synced', 'pending', 'error')
* @returns Array of entities with this sync status
*/
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.service.db.transaction([this.service.storeName], 'readonly');
const store = transaction.objectStore(this.service.storeName);
const index = store.index('syncStatus');
const request = index.getAll(syncStatus);
request.onsuccess = () => {
const data = request.result as any[];
const entities = data.map(item => this.service.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.service.entityType}s by sync status ${syncStatus}: ${request.error}`));
};
});
}
}

View file

@ -1,115 +1,43 @@
import { IBooking } from '../../types/BookingTypes';
import { EntityType } from '../../types/CalendarTypes';
import { BookingStore } from './BookingStore';
import { BookingSerialization } from './BookingSerialization';
import { BaseEntityService } from '../BaseEntityService';
/**
* BookingService - CRUD operations for bookings in IndexedDB
*
* Handles all booking-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - Overrides serialize/deserialize for Date field conversion (createdAt)
* - Provides booking-specific query methods (by customer, by status)
*
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* BOOKING-SPECIFIC METHODS:
* - getByCustomer(customerId)
* - getByStatus(status)
*/
export class BookingService {
private db: IDBDatabase;
export class BookingService extends BaseEntityService<IBooking> {
readonly storeName = BookingStore.STORE_NAME;
readonly entityType: EntityType = 'Booking';
/**
* @param db - IDBDatabase instance (injected dependency)
* Serialize booking for IndexedDB storage
* Converts Date objects to ISO strings
*/
constructor(db: IDBDatabase) {
this.db = db;
protected serialize(booking: IBooking): any {
return BookingSerialization.serialize(booking);
}
/**
* Get a single booking by ID
*
* @param id - Booking ID
* @returns IBooking or null if not found
* Deserialize booking from IndexedDB
* Converts ISO strings back to Date objects
*/
async get(id: string): Promise<IBooking | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
resolve(BookingSerialization.deserialize(data));
} else {
resolve(null);
}
};
request.onerror = () => {
reject(new Error(`Failed to get booking ${id}: ${request.error}`));
};
});
}
/**
* Get all bookings
*
* @returns Array of all bookings
*/
async getAll(): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as any[];
const bookings = data.map(item => BookingSerialization.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get all bookings: ${request.error}`));
};
});
}
/**
* Save a booking (create or update)
*
* @param booking - IBooking to save
*/
async save(booking: IBooking): Promise<void> {
const serialized = BookingSerialization.serialize(booking);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save booking ${booking.id}: ${request.error}`));
};
});
}
/**
* Delete a booking
*
* @param id - Booking ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete booking ${id}: ${request.error}`));
};
});
protected deserialize(data: any): IBooking {
return BookingSerialization.deserialize(data);
}
/**
@ -120,14 +48,14 @@ export class BookingService {
*/
async getByCustomer(customerId: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('customerId');
const request = index.getAll(customerId);
request.onsuccess = () => {
const data = request.result as any[];
const bookings = data.map(item => BookingSerialization.deserialize(item));
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
@ -145,14 +73,14 @@ export class BookingService {
*/
async getByStatus(status: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([BookingStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(BookingStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('status');
const request = index.getAll(status);
request.onsuccess = () => {
const data = request.result as any[];
const bookings = data.map(item => BookingSerialization.deserialize(item));
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};

View file

@ -29,6 +29,9 @@ export class BookingStore implements IStore {
// Index: status (for filtering by booking status)
store.createIndex('status', 'status', { unique: false });
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
// Index: createdAt (for sorting bookings chronologically)
store.createIndex('createdAt', 'createdAt', { unique: false });
}

View file

@ -1,108 +1,29 @@
import { ICustomer } from '../../types/CustomerTypes';
import { EntityType } from '../../types/CalendarTypes';
import { CustomerStore } from './CustomerStore';
import { BaseEntityService } from '../BaseEntityService';
/**
* CustomerService - CRUD operations for customers in IndexedDB
*
* Handles all customer-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - No serialization needed (ICustomer has no Date fields)
* - Provides customer-specific query methods (by phone, search by name)
*
* Note: No serialization needed - ICustomer has no Date fields.
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* CUSTOMER-SPECIFIC METHODS:
* - getByPhone(phone)
* - searchByName(searchTerm)
*/
export class CustomerService {
private db: IDBDatabase;
export class CustomerService extends BaseEntityService<ICustomer> {
readonly storeName = CustomerStore.STORE_NAME;
readonly entityType: EntityType = 'Customer';
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
}
/**
* Get a single customer by ID
*
* @param id - Customer ID
* @returns ICustomer or null if not found
*/
async get(id: string): Promise<ICustomer | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get customer ${id}: ${request.error}`));
};
});
}
/**
* Get all customers
*
* @returns Array of all customers
*/
async getAll(): Promise<ICustomer[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result as ICustomer[]);
};
request.onerror = () => {
reject(new Error(`Failed to get all customers: ${request.error}`));
};
});
}
/**
* Save a customer (create or update)
*
* @param customer - ICustomer to save
*/
async save(customer: ICustomer): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.put(customer);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save customer ${customer.id}: ${request.error}`));
};
});
}
/**
* Delete a customer
*
* @param id - Customer ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete customer ${id}: ${request.error}`));
};
});
}
// No serialization override needed - ICustomer has no Date fields
/**
* Get customers by phone number
@ -112,8 +33,8 @@ export class CustomerService {
*/
async getByPhone(phone: string): Promise<ICustomer[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([CustomerStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(CustomerStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('phone');
const request = index.getAll(phone);

View file

@ -28,5 +28,8 @@ export class CustomerStore implements IStore {
// Index: phone (for customer lookup by phone)
store.createIndex('phone', 'phone', { unique: false });
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -1,115 +1,45 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
import { ICalendarEvent, EntityType } from '../../types/CalendarTypes';
import { EventStore } from './EventStore';
import { EventSerialization } from './EventSerialization';
import { BaseEntityService } from '../BaseEntityService';
/**
* EventService - CRUD operations for calendar events in IndexedDB
*
* Handles all event-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - Overrides serialize/deserialize for Date field conversion
* - Provides event-specific query methods (by date range, resource, customer, booking)
*
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* EVENT-SPECIFIC METHODS:
* - getByDateRange(start, end)
* - getByResource(resourceId)
* - getByCustomer(customerId)
* - getByBooking(bookingId)
* - getByResourceAndDateRange(resourceId, start, end)
*/
export class EventService {
private db: IDBDatabase;
export class EventService extends BaseEntityService<ICalendarEvent> {
readonly storeName = EventStore.STORE_NAME;
readonly entityType: EntityType = 'Event';
/**
* @param db - IDBDatabase instance (injected dependency)
* Serialize event for IndexedDB storage
* Converts Date objects to ISO strings
*/
constructor(db: IDBDatabase) {
this.db = db;
protected serialize(event: ICalendarEvent): any {
return EventSerialization.serialize(event);
}
/**
* Get a single event by ID
*
* @param id - Event ID
* @returns ICalendarEvent or null if not found
* Deserialize event from IndexedDB
* Converts ISO strings back to Date objects
*/
async get(id: string): Promise<ICalendarEvent | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
resolve(EventSerialization.deserialize(data));
} else {
resolve(null);
}
};
request.onerror = () => {
reject(new Error(`Failed to get event ${id}: ${request.error}`));
};
});
}
/**
* Get all events
*
* @returns Array of all events
*/
async getAll(): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get all events: ${request.error}`));
};
});
}
/**
* Save an event (create or update)
*
* @param event - ICalendarEvent to save
*/
async save(event: ICalendarEvent): Promise<void> {
const serialized = EventSerialization.serialize(event);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.put(serialized);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save event ${event.id}: ${request.error}`));
};
});
}
/**
* Delete an event
*
* @param id - Event ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(EventStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete event ${id}: ${request.error}`));
};
});
protected deserialize(data: any): ICalendarEvent {
return EventSerialization.deserialize(data);
}
/**
@ -122,8 +52,8 @@ export class EventService {
*/
async getByDateRange(start: Date, end: Date): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('start');
// Get all events starting from start date
@ -135,7 +65,7 @@ export class EventService {
// Deserialize and filter in memory
const events = data
.map(item => EventSerialization.deserialize(item))
.map(item => this.deserialize(item))
.filter(event => event.start <= end);
resolve(events);
@ -155,14 +85,14 @@ export class EventService {
*/
async getByResource(resourceId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('resourceId');
const request = index.getAll(resourceId);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
const events = data.map(item => this.deserialize(item));
resolve(events);
};
@ -180,14 +110,14 @@ export class EventService {
*/
async getByCustomer(customerId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('customerId');
const request = index.getAll(customerId);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
const events = data.map(item => this.deserialize(item));
resolve(events);
};
@ -205,14 +135,14 @@ export class EventService {
*/
async getByBooking(bookingId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('bookingId');
const request = index.getAll(bookingId);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
const events = data.map(item => this.deserialize(item));
resolve(events);
};
@ -222,31 +152,6 @@ export class EventService {
});
}
/**
* Get events by sync status
*
* @param syncStatus - Sync status ('synced', 'pending', 'error')
* @returns Array of events with this sync status
*/
async getBySyncStatus(syncStatus: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([EventStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(EventStore.STORE_NAME);
const index = store.index('syncStatus');
const request = index.getAll(syncStatus);
request.onsuccess = () => {
const data = request.result as any[];
const events = data.map(item => EventSerialization.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events by sync status ${syncStatus}: ${request.error}`));
};
});
}
/**
* Get events for a resource within a date range
* Combines resource and date filtering

View file

@ -1,108 +1,30 @@
import { IResource } from '../../types/ResourceTypes';
import { EntityType } from '../../types/CalendarTypes';
import { ResourceStore } from './ResourceStore';
import { BaseEntityService } from '../BaseEntityService';
/**
* ResourceService - CRUD operations for resources in IndexedDB
*
* Handles all resource-related database operations.
* Part of modular storage architecture where each entity has its own service.
* ARCHITECTURE:
* - Extends BaseEntityService for shared CRUD and sync logic
* - No serialization needed (IResource has no Date fields)
* - Provides resource-specific query methods (by type, active/inactive)
*
* Note: No serialization needed - IResource has no Date fields.
* INHERITED METHODS (from BaseEntityService):
* - get(id), getAll(), save(entity), delete(id)
* - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status)
*
* RESOURCE-SPECIFIC METHODS:
* - getByType(type)
* - getActive()
* - getInactive()
*/
export class ResourceService {
private db: IDBDatabase;
export class ResourceService extends BaseEntityService<IResource> {
readonly storeName = ResourceStore.STORE_NAME;
readonly entityType: EntityType = 'Resource';
/**
* @param db - IDBDatabase instance (injected dependency)
*/
constructor(db: IDBDatabase) {
this.db = db;
}
/**
* Get a single resource by ID
*
* @param id - Resource ID
* @returns IResource or null if not found
*/
async get(id: string): Promise<IResource | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get resource ${id}: ${request.error}`));
};
});
}
/**
* Get all resources
*
* @returns Array of all resources
*/
async getAll(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get all resources: ${request.error}`));
};
});
}
/**
* Save a resource (create or update)
*
* @param resource - IResource to save
*/
async save(resource: IResource): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.put(resource);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save resource ${resource.id}: ${request.error}`));
};
});
}
/**
* Delete a resource
*
* @param id - Resource ID to delete
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete resource ${id}: ${request.error}`));
};
});
}
// No serialization override needed - IResource has no Date fields
/**
* Get resources by type
@ -112,8 +34,8 @@ export class ResourceService {
*/
async getByType(type: string): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('type');
const request = index.getAll(type);
@ -134,10 +56,10 @@ export class ResourceService {
*/
async getActive(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('isActive');
const request = index.getAll(true);
const request = index.getAll(IDBKeyRange.only(true));
request.onsuccess = () => {
resolve(request.result as IResource[]);
@ -156,10 +78,10 @@ export class ResourceService {
*/
async getInactive(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ResourceStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ResourceStore.STORE_NAME);
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('isActive');
const request = index.getAll(false);
const request = index.getAll(IDBKeyRange.only(false));
request.onsuccess = () => {
resolve(request.result as IResource[]);

View file

@ -28,5 +28,8 @@ export class ResourceStore implements IStore {
// Index: isActive (for showing/hiding inactive resources)
store.createIndex('isActive', 'isActive', { unique: false });
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -1,3 +1,5 @@
import { ISync } from './CalendarTypes';
/**
* Booking entity - represents customer service bookings ONLY
*
@ -18,7 +20,7 @@
*
* Matches backend Booking table structure
*/
export interface IBooking {
export interface IBooking extends ISync {
id: string;
customerId: string; // REQUIRED - booking is always for a customer
status: BookingStatus;

View file

@ -9,13 +9,35 @@ export type CalendarView = ViewPeriod;
export type SyncStatus = 'synced' | 'pending' | 'error';
/**
* EntityType - Discriminator for all syncable entities
*/
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource';
/**
* ISync - Interface composition for sync status tracking
* All syncable entities should extend this interface
*/
export interface ISync {
syncStatus: SyncStatus;
}
/**
* IDataEntity - Wrapper for entity data with typename discriminator
* Used in queue operations and API calls to preserve type information at runtime
*/
export interface IDataEntity {
typename: EntityType;
data: any;
}
export interface IRenderContext {
container: HTMLElement;
startDate: Date;
endDate: Date;
}
export interface ICalendarEvent {
export interface ICalendarEvent extends ISync {
id: string;
title: string;
description?: string;
@ -23,7 +45,6 @@ export interface ICalendarEvent {
end: Date;
type: CalendarEventType; // Event type - only 'customer' has associated booking
allDay: boolean;
syncStatus: SyncStatus;
// References (denormalized for IndexedDB performance)
bookingId?: string; // Reference to booking (only if type = 'customer')

View file

@ -1,8 +1,10 @@
import { ISync } from './CalendarTypes';
/**
* Customer entity
* Matches backend Customer table structure
*/
export interface ICustomer {
export interface ICustomer extends ISync {
id: string;
name: string;
phone: string;

View file

@ -1,8 +1,10 @@
import { ISync } from './CalendarTypes';
/**
* Resource entity - represents people, rooms, equipment, etc.
* Matches backend Resource table structure
*/
export interface IResource {
export interface IResource extends ISync {
id: string; // Primary key (e.g., "EMP001", "ROOM-A")
name: string; // Machine name (e.g., "karina.knudsen")
displayName: string; // Human-readable name (e.g., "Karina Knudsen")

View file

@ -1,14 +1,28 @@
import { IEventBus } from '../types/CalendarTypes';
import { IEventBus, EntityType, ISync } 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';
import { IApiRepository } from '../repositories/IApiRepository';
import { IEntityService } from '../storage/IEntityService';
/**
* SyncManager - Background sync worker
* Processes operation queue and syncs with API when online
*
* GENERIC ARCHITECTURE:
* - Handles all entity types (Event, Booking, Customer, Resource)
* - Routes operations based on IQueueOperation.dataEntity.typename
* - Uses IApiRepository<T> pattern for type-safe API calls
* - Uses IEntityService<T> polymorphism for sync status management
*
* POLYMORFI DESIGN:
* - Services implement IEntityService<T extends ISync> interface
* - SyncManager uses Array.find() for service lookup (simple, only 4 entities)
* - Services encapsulate sync status manipulation (markAsSynced, markAsError)
* - SyncManager does NOT manipulate entity.syncStatus directly
* - Open/Closed Principle: Adding new entity requires only DI registration
*
* Features:
* - Monitors online/offline status
* - Processes queue with FIFO order
@ -20,7 +34,8 @@ export class SyncManager {
private eventBus: IEventBus;
private queue: OperationQueue;
private indexedDB: IndexedDBService;
private apiRepository: ApiEventRepository;
private repositories: Map<EntityType, IApiRepository<any>>;
private entityServices: IEntityService<any>[];
private isOnline: boolean = navigator.onLine;
private isSyncing: boolean = false;
@ -32,16 +47,22 @@ export class SyncManager {
eventBus: IEventBus,
queue: OperationQueue,
indexedDB: IndexedDBService,
apiRepository: ApiEventRepository
apiRepositories: IApiRepository<any>[],
entityServices: IEntityService<any>[]
) {
this.eventBus = eventBus;
this.queue = queue;
this.indexedDB = indexedDB;
this.apiRepository = apiRepository;
this.entityServices = entityServices;
// Build map: EntityType → IApiRepository
this.repositories = new Map(
apiRepositories.map(repo => [repo.entityType, repo])
);
this.setupNetworkListeners();
this.startSync();
console.log('SyncManager initialized and started');
console.log(`SyncManager initialized with ${apiRepositories.length} entity repositories and ${entityServices.length} entity services`);
}
/**
@ -147,13 +168,22 @@ export class SyncManager {
/**
* Process a single operation
* Generic - routes to correct API repository based on entity type
*/
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);
await this.markEntityAsError(operation.dataEntity.typename, operation.entityId);
return;
}
// Get the appropriate API repository for this entity type
const repository = this.repositories.get(operation.dataEntity.typename);
if (!repository) {
console.error(`SyncManager: No repository found for entity type ${operation.dataEntity.typename}`);
await this.queue.remove(operation.id);
return;
}
@ -161,15 +191,15 @@ export class SyncManager {
// Send to API based on operation type
switch (operation.type) {
case 'create':
await this.apiRepository.sendCreate(operation.data as any);
await repository.sendCreate(operation.dataEntity.data);
break;
case 'update':
await this.apiRepository.sendUpdate(operation.eventId, operation.data);
await repository.sendUpdate(operation.entityId, operation.dataEntity.data);
break;
case 'delete':
await this.apiRepository.sendDelete(operation.eventId);
await repository.sendDelete(operation.entityId);
break;
default:
@ -180,9 +210,9 @@ export class SyncManager {
// Success - remove from queue and mark as synced
await this.queue.remove(operation.id);
await this.markEventAsSynced(operation.eventId);
await this.markEntityAsSynced(operation.dataEntity.typename, operation.entityId);
console.log(`SyncManager: Successfully synced operation ${operation.id}`);
console.log(`SyncManager: Successfully synced ${operation.dataEntity.typename} operation ${operation.id}`);
} catch (error) {
console.error(`SyncManager: Failed to sync operation ${operation.id}:`, error);
@ -202,32 +232,38 @@ export class SyncManager {
}
/**
* Mark event as synced in IndexedDB
* Mark entity as synced in IndexedDB
* Uses polymorphism - delegates to IEntityService.markAsSynced()
*/
private async markEventAsSynced(eventId: string): Promise<void> {
private async markEntityAsSynced(entityType: EntityType, entityId: string): Promise<void> {
try {
const event = await this.indexedDB.getEvent(eventId);
if (event) {
event.syncStatus = 'synced';
await this.indexedDB.saveEvent(event);
const service = this.entityServices.find(s => s.entityType === entityType);
if (!service) {
console.error(`SyncManager: No service found for entity type ${entityType}`);
return;
}
await service.markAsSynced(entityId);
} catch (error) {
console.error(`SyncManager: Failed to mark event ${eventId} as synced:`, error);
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as synced:`, error);
}
}
/**
* Mark event as error in IndexedDB
* Mark entity as error in IndexedDB
* Uses polymorphism - delegates to IEntityService.markAsError()
*/
private async markEventAsError(eventId: string): Promise<void> {
private async markEntityAsError(entityType: EntityType, entityId: string): Promise<void> {
try {
const event = await this.indexedDB.getEvent(eventId);
if (event) {
event.syncStatus = 'error';
await this.indexedDB.saveEvent(event);
const service = this.entityServices.find(s => s.entityType === entityType);
if (!service) {
console.error(`SyncManager: No service found for entity type ${entityType}`);
return;
}
await service.markAsError(entityId);
} catch (error) {
console.error(`SyncManager: Failed to mark event ${eventId} as error:`, error);
console.error(`SyncManager: Failed to mark ${entityType} ${entityId} as error:`, error);
}
}