Refactors calendar project structure and build configuration

Consolidates V2 codebase into main project directory
Updates build script to support simplified entry points
Removes redundant files and cleans up project organization

Simplifies module imports and entry points for calendar application
This commit is contained in:
Janus C. H. Knudsen 2025-12-17 23:54:25 +01:00
parent 9f360237cf
commit 863b433eba
200 changed files with 2331 additions and 16193 deletions

View file

@ -1,266 +1,181 @@
import { ISync, EntityType, SyncStatus, IEventBus } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
import { IndexedDBContext } from './IndexedDBContext';
import { CoreEvents } from '../constants/CoreEvents';
import { diff } from 'json-diff-ts';
import { IEntitySavedPayload, IEntityDeletedPayload } from '../types/EventTypes';
/**
* 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)
* - Lazy database access via IndexedDBContext
*
* 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
* - Lazy database access: db requested when needed, not at construction time
*/
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>;
// IndexedDB context - provides database connection
private context: IndexedDBContext;
// EventBus for emitting entity events
protected eventBus: IEventBus;
/**
* @param context - IndexedDBContext instance (injected dependency)
* @param eventBus - EventBus for emitting entity events
*/
constructor(context: IndexedDBContext, eventBus: IEventBus) {
this.context = context;
this.eventBus = eventBus;
this.syncPlugin = new SyncPlugin<T>(this);
}
/**
* Get IDBDatabase instance (lazy access)
* Protected getter accessible to subclasses and methods in this class
*/
protected get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* 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)
* Emits ENTITY_SAVED event with operation type and changes
*
* @param entity - Entity to save
*/
async save(entity: T): Promise<void> {
const entityId = (entity as any).id;
// Check if entity exists to determine create vs update
const existingEntity = await this.get(entityId);
const isCreate = existingEntity === null;
// Calculate changes: full entity for create, diff for update
let changes: any;
if (isCreate) {
changes = entity;
} else {
// Calculate diff between existing and new entity
const existingSerialized = this.serialize(existingEntity);
const newSerialized = this.serialize(entity);
changes = diff(existingSerialized, newSerialized);
}
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 = () => {
// Emit ENTITY_SAVED event
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entityId,
operation: isCreate ? 'create' : 'update',
changes,
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
};
});
}
/**
* Delete an entity
* Emits ENTITY_DELETED event
*
* @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 = () => {
// Emit ENTITY_DELETED event
const payload: IEntityDeletedPayload = {
entityType: this.entityType,
entityId: id,
operation: 'delete',
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
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);
}
}
import { ISync, EntityType, SyncStatus, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
import { IndexedDBContext } from './IndexedDBContext';
import { CoreEvents } from '../constants/CoreEvents';
import { diff } from 'json-diff-ts';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
*
* PROVIDES:
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
*/
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
abstract readonly storeName: string;
abstract readonly entityType: EntityType;
private syncPlugin: SyncPlugin<T>;
private context: IndexedDBContext;
protected eventBus: IEventBus;
constructor(context: IndexedDBContext, eventBus: IEventBus) {
this.context = context;
this.eventBus = eventBus;
this.syncPlugin = new SyncPlugin<T>(this);
}
protected get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Serialize entity before storing in IndexedDB
*/
protected serialize(entity: T): unknown {
return entity;
}
/**
* Deserialize data from IndexedDB back to entity
*/
protected deserialize(data: unknown): T {
return data as T;
}
/**
* Get a single entity by ID
*/
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;
resolve(data ? this.deserialize(data) : null);
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`));
};
});
}
/**
* Get 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 unknown[];
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)
* Emits ENTITY_SAVED event with operation type and changes (diff for updates)
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
async save(entity: T, silent = false): Promise<void> {
const entityId = (entity as unknown as { id: string }).id;
const existingEntity = await this.get(entityId);
const isCreate = existingEntity === null;
// Calculate changes: full entity for create, diff for update
let changes: unknown;
if (isCreate) {
changes = entity;
} else {
const existingSerialized = this.serialize(existingEntity);
const newSerialized = this.serialize(entity);
changes = diff(existingSerialized, newSerialized);
}
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 = () => {
// Only emit event if not silent (silent used for seeding)
if (!silent) {
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entityId,
operation: isCreate ? 'create' : 'update',
changes,
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
}
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
};
});
}
/**
* Delete an entity
* Emits ENTITY_DELETED event
*/
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 = () => {
const payload: IEntityDeletedPayload = {
entityType: this.entityType,
entityId: id,
operation: 'delete',
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`));
};
});
}
// Sync methods - delegate to SyncPlugin
async markAsSynced(id: string): Promise<void> {
return this.syncPlugin.markAsSynced(id);
}
async markAsError(id: string): Promise<void> {
return this.syncPlugin.markAsError(id);
}
async getSyncStatus(id: string): Promise<SyncStatus | null> {
return this.syncPlugin.getSyncStatus(id);
}
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return this.syncPlugin.getBySyncStatus(syncStatus);
}
}

View file

@ -1,70 +1,40 @@
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 operations across different entity types.
*
* ENCAPSULATION: Services encapsulate sync status manipulation.
* SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service.
*
* POLYMORPHISM: Both SyncManager and DataSeeder work with Array<IEntityService<any>>
* and use 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;
// ============================================================================
// CRUD Operations (used by DataSeeder and other consumers)
// ============================================================================
/**
* Get all entities from IndexedDB
* Used by DataSeeder to check if store is empty before seeding
*
* @returns Promise<T[]> - Array of all entities
*/
getAll(): Promise<T[]>;
/**
* Save an entity (create or update) to IndexedDB
* Used by DataSeeder to persist fetched data
*
* @param entity - Entity to save
*/
save(entity: T): Promise<void>;
// ============================================================================
// SYNC Methods (used by SyncManager)
// ============================================================================
/**
* 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>;
}
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
/**
* IEntityService<T> - Generic interface for entity services with sync capabilities
*
* All entity services implement this interface to enable polymorphic operations.
*/
export interface IEntityService<T extends ISync> {
/**
* Entity type discriminator for runtime routing
*/
readonly entityType: EntityType;
/**
* Get all entities from IndexedDB
*/
getAll(): Promise<T[]>;
/**
* Save an entity (create or update) to IndexedDB
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
save(entity: T, silent?: boolean): Promise<void>;
/**
* Mark entity as successfully synced
*/
markAsSynced(id: string): Promise<void>;
/**
* Mark entity as sync error
*/
markAsError(id: string): Promise<void>;
/**
* Get current sync status for an entity
*/
getSyncStatus(id: string): Promise<SyncStatus | null>;
}

View file

@ -1,25 +1,18 @@
/**
* IStore - Interface for IndexedDB ObjectStore definitions
*
* Each entity store (bookings, customers, resources, events) implements this interface
* to define its schema and creation logic.
*
* This enables Open/Closed Principle: IndexedDBService can work with any IStore
* implementation without modification. Adding new entities only requires:
* 1. Create new Store class implementing IStore
* 2. Register in DI container as IStore
*/
export interface IStore {
/**
* The name of the ObjectStore in IndexedDB
*/
readonly storeName: string;
/**
* Create the ObjectStore with its schema (indexes, keyPath, etc.)
* Called during database upgrade (onupgradeneeded event)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void;
}
/**
* IStore - Interface for IndexedDB ObjectStore definitions
*
* Each entity store implements this interface to define its schema.
* Enables Open/Closed Principle: IndexedDBContext works with any IStore.
*/
export interface IStore {
/**
* The name of the ObjectStore in IndexedDB
*/
readonly storeName: string;
/**
* Create the ObjectStore with its schema (indexes, keyPath, etc.)
* Called during database upgrade (onupgradeneeded event)
*/
create(db: IDBDatabase): void;
}

View file

@ -1,128 +1,92 @@
import { IStore } from './IStore';
/**
* IndexedDBContext - Database connection manager and provider
*
* RESPONSIBILITY:
* - Opens and manages IDBDatabase connection lifecycle
* - Creates object stores via injected IStore implementations
* - Provides shared IDBDatabase instance to all services
*
* SEPARATION OF CONCERNS:
* - This class: Connection management ONLY
* - OperationQueue: Queue and sync state operations
* - Entity Services: CRUD operations for specific entities
*
* USAGE:
* Services inject IndexedDBContext and call getDatabase() to access db.
* This lazy access pattern ensures db is ready when requested.
*/
export class IndexedDBContext {
private static readonly DB_NAME = 'CalendarDB';
private static readonly DB_VERSION = 5; // Bumped to add syncStatus index to resources
static readonly QUEUE_STORE = 'operationQueue';
static readonly SYNC_STATE_STORE = 'syncState';
private db: IDBDatabase | null = null;
private initialized: boolean = false;
private stores: IStore[];
/**
* @param stores - Array of IStore implementations injected via DI
*/
constructor(stores: IStore[]) {
this.stores = stores;
}
/**
* Initialize and open the database
* Creates all entity stores, queue store, and sync state store
*/
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IndexedDBContext.DB_NAME, IndexedDBContext.DB_VERSION);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
this.initialized = true;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create all entity stores via injected IStore implementations
// Open/Closed Principle: Adding new entity only requires DI registration
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.storeName)) {
store.create(db);
}
});
// Create operation queue store (sync infrastructure)
if (!db.objectStoreNames.contains(IndexedDBContext.QUEUE_STORE)) {
const queueStore = db.createObjectStore(IndexedDBContext.QUEUE_STORE, { keyPath: 'id' });
queueStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// Create sync state store (sync metadata)
if (!db.objectStoreNames.contains(IndexedDBContext.SYNC_STATE_STORE)) {
db.createObjectStore(IndexedDBContext.SYNC_STATE_STORE, { keyPath: 'key' });
}
};
});
}
/**
* Check if database is initialized
*/
public isInitialized(): boolean {
return this.initialized;
}
/**
* Get IDBDatabase instance
* Used by services to access the database
*
* @throws Error if database not initialized
* @returns IDBDatabase instance
*/
public getDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB not initialized. Call initialize() first.');
}
return this.db;
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
this.initialized = false;
}
}
/**
* Delete entire database (for testing/reset)
*/
static async deleteDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(IndexedDBContext.DB_NAME);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete database: ${request.error}`));
};
});
}
}
import { IStore } from './IStore';
/**
* IndexedDBContext - Database connection manager
*
* RESPONSIBILITY:
* - Opens and manages IDBDatabase connection lifecycle
* - Creates object stores via injected IStore implementations
* - Provides shared IDBDatabase instance to all services
*/
export class IndexedDBContext {
private static readonly DB_NAME = 'CalendarDB';
private static readonly DB_VERSION = 4;
private db: IDBDatabase | null = null;
private initialized: boolean = false;
private stores: IStore[];
constructor(stores: IStore[]) {
this.stores = stores;
}
/**
* Initialize and open the database
*/
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IndexedDBContext.DB_NAME, IndexedDBContext.DB_VERSION);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
this.initialized = true;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create all entity stores via injected IStore implementations
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.storeName)) {
store.create(db);
}
});
};
});
}
/**
* Check if database is initialized
*/
public isInitialized(): boolean {
return this.initialized;
}
/**
* Get IDBDatabase instance
*/
public getDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB not initialized. Call initialize() first.');
}
return this.db;
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
this.initialized = false;
}
}
/**
* Delete entire database (for testing/reset)
*/
static async deleteDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(IndexedDBContext.DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error(`Failed to delete database: ${request.error}`));
});
}
}

View file

@ -1,90 +1,64 @@
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}`));
};
});
}
}
import { ISync, SyncStatus } 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)
*/
export class SyncPlugin<T extends ISync> {
constructor(private service: any) {}
/**
* Mark entity as successfully synced
*/
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
*/
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
*/
async getSyncStatus(id: string): Promise<SyncStatus | null> {
const entity = await this.service.get(id);
return entity ? entity.syncStatus : null;
}
/**
* Get entities by sync status using IndexedDB index
*/
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 unknown[];
const entities = data.map(item => this.service.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get by sync status ${syncStatus}: ${request.error}`));
};
});
}
}

View file

@ -1,9 +1,8 @@
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
import { IAuditEntry } from '../../types/AuditTypes';
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes';
import { EntityType, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../../types/CalendarTypes';
import { CoreEvents } from '../../constants/CoreEvents';
import { IEntitySavedPayload, IEntityDeletedPayload, IAuditLoggedPayload } from '../../types/EventTypes';
/**
* AuditService - Entity service for audit entries

View file

@ -11,15 +11,17 @@ import { IStore } from '../IStore';
* Indexes:
* - syncStatus: For finding pending entries to sync
* - synced: Boolean flag for quick sync queries
* - entityId: For getting all audits for a specific entity
* - timestamp: For chronological queries
*/
export class AuditStore implements IStore {
readonly storeName = 'audit';
readonly storeName = 'audit';
create(db: IDBDatabase): void {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
store.createIndex('entityId', 'entityId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
create(db: IDBDatabase): void {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
store.createIndex('entityId', 'entityId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
}

View file

@ -1,42 +0,0 @@
import { IBooking } from '../../types/BookingTypes';
/**
* BookingSerialization - Handles Date field serialization for IndexedDB
*
* IndexedDB doesn't store Date objects directly, so we convert:
* - Date ISO string (serialize) when writing to IndexedDB
* - ISO string Date (deserialize) when reading from IndexedDB
*/
export class BookingSerialization {
/**
* Serialize booking for IndexedDB storage
* Converts Date fields to ISO strings
*
* @param booking - IBooking with Date objects
* @returns Plain object with ISO string dates
*/
static serialize(booking: IBooking): any {
return {
...booking,
createdAt: booking.createdAt instanceof Date
? booking.createdAt.toISOString()
: booking.createdAt
};
}
/**
* Deserialize booking from IndexedDB storage
* Converts ISO string dates back to Date objects
*
* @param data - Plain object from IndexedDB with ISO string dates
* @returns IBooking with Date objects
*/
static deserialize(data: any): IBooking {
return {
...data,
createdAt: typeof data.createdAt === 'string'
? new Date(data.createdAt)
: data.createdAt
};
}
}

View file

@ -1,97 +1,75 @@
import { IBooking } from '../../types/BookingTypes';
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { BookingStore } from './BookingStore';
import { BookingSerialization } from './BookingSerialization';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* BookingService - CRUD operations for bookings in IndexedDB
*
* 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 extends BaseEntityService<IBooking> {
readonly storeName = BookingStore.STORE_NAME;
readonly entityType: EntityType = 'Booking';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Serialize booking for IndexedDB storage
* Converts Date objects to ISO strings
*/
protected serialize(booking: IBooking): any {
return BookingSerialization.serialize(booking);
}
/**
* Deserialize booking from IndexedDB
* Converts ISO strings back to Date objects
*/
protected deserialize(data: any): IBooking {
return BookingSerialization.deserialize(data);
}
/**
* Get bookings by customer ID
*
* @param customerId - Customer ID
* @returns Array of bookings for this customer
*/
async getByCustomer(customerId: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
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 => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings for customer ${customerId}: ${request.error}`));
};
});
}
/**
* Get bookings by status
*
* @param status - Booking status
* @returns Array of bookings with this status
*/
async getByStatus(status: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
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 => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`));
};
});
}
}
import { IBooking, EntityType, IEventBus, BookingStatus } from '../../types/CalendarTypes';
import { BookingStore } from './BookingStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* BookingService - CRUD operations for bookings in IndexedDB
*/
export class BookingService extends BaseEntityService<IBooking> {
readonly storeName = BookingStore.STORE_NAME;
readonly entityType: EntityType = 'Booking';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
protected serialize(booking: IBooking): unknown {
return {
...booking,
createdAt: booking.createdAt.toISOString()
};
}
protected deserialize(data: unknown): IBooking {
const raw = data as Record<string, unknown>;
return {
...raw,
createdAt: new Date(raw.createdAt as string)
} as IBooking;
}
/**
* Get bookings for a customer
*/
async getByCustomer(customerId: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
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 unknown[];
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings for customer ${customerId}: ${request.error}`));
};
});
}
/**
* Get bookings by status
*/
async getByStatus(status: BookingStatus): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
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 unknown[];
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`));
};
});
}
}

View file

@ -1,38 +1,18 @@
import { IStore } from '../IStore';
/**
* BookingStore - IndexedDB ObjectStore definition for bookings
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class BookingStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'bookings';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = BookingStore.STORE_NAME;
/**
* Create the bookings ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' });
// Index: customerId (for querying bookings by customer)
store.createIndex('customerId', 'customerId', { unique: false });
// 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 });
}
}
import { IStore } from '../IStore';
/**
* BookingStore - IndexedDB ObjectStore definition for bookings
*/
export class BookingStore implements IStore {
static readonly STORE_NAME = 'bookings';
readonly storeName = BookingStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('customerId', 'customerId', { unique: false });
store.createIndex('status', 'status', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
}

View file

@ -1,68 +1,46 @@
import { ICustomer } from '../../types/CustomerTypes';
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { CustomerStore } from './CustomerStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* CustomerService - CRUD operations for customers in IndexedDB
*
* 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)
*
* 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 extends BaseEntityService<ICustomer> {
readonly storeName = CustomerStore.STORE_NAME;
readonly entityType: EntityType = 'Customer';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get customers by phone number
*
* @param phone - Phone number
* @returns Array of customers with this phone
*/
async getByPhone(phone: string): Promise<ICustomer[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('phone');
const request = index.getAll(phone);
request.onsuccess = () => {
resolve(request.result as ICustomer[]);
};
request.onerror = () => {
reject(new Error(`Failed to get customers by phone ${phone}: ${request.error}`));
};
});
}
/**
* Search customers by name (partial match)
*
* @param searchTerm - Search term (case insensitive)
* @returns Array of customers matching search
*/
async searchByName(searchTerm: string): Promise<ICustomer[]> {
const allCustomers = await this.getAll();
const lowerSearch = searchTerm.toLowerCase();
return allCustomers.filter(customer =>
customer.name.toLowerCase().includes(lowerSearch)
);
}
}
import { ICustomer, EntityType, IEventBus } from '../../types/CalendarTypes';
import { CustomerStore } from './CustomerStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* CustomerService - CRUD operations for customers in IndexedDB
*/
export class CustomerService extends BaseEntityService<ICustomer> {
readonly storeName = CustomerStore.STORE_NAME;
readonly entityType: EntityType = 'Customer';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Search customers by name (case-insensitive contains)
*/
async searchByName(query: string): Promise<ICustomer[]> {
const all = await this.getAll();
const lowerQuery = query.toLowerCase();
return all.filter(c => c.name.toLowerCase().includes(lowerQuery));
}
/**
* Find customer by phone
*/
async getByPhone(phone: string): Promise<ICustomer | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('phone');
const request = index.get(phone);
request.onsuccess = () => {
const data = request.result;
resolve(data ? (data as ICustomer) : null);
};
request.onerror = () => {
reject(new Error(`Failed to find customer by phone ${phone}: ${request.error}`));
};
});
}
}

View file

@ -1,35 +1,17 @@
import { IStore } from '../IStore';
/**
* CustomerStore - IndexedDB ObjectStore definition for customers
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class CustomerStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'customers';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = CustomerStore.STORE_NAME;
/**
* Create the customers ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' });
// Index: name (for customer search/lookup)
store.createIndex('name', 'name', { unique: false });
// 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 });
}
}
import { IStore } from '../IStore';
/**
* CustomerStore - IndexedDB ObjectStore definition for customers
*/
export class CustomerStore implements IStore {
static readonly STORE_NAME = 'customers';
readonly storeName = CustomerStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
store.createIndex('phone', 'phone', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -0,0 +1,25 @@
import { IDepartment, EntityType, IEventBus } from '../../types/CalendarTypes';
import { DepartmentStore } from './DepartmentStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* DepartmentService - CRUD operations for departments in IndexedDB
*/
export class DepartmentService extends BaseEntityService<IDepartment> {
readonly storeName = DepartmentStore.STORE_NAME;
readonly entityType: EntityType = 'Department';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get departments by IDs
*/
async getByIds(ids: string[]): Promise<IDepartment[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((d): d is IDepartment => d !== null);
}
}

View file

@ -0,0 +1,13 @@
import { IStore } from '../IStore';
/**
* DepartmentStore - IndexedDB ObjectStore definition for departments
*/
export class DepartmentStore implements IStore {
static readonly STORE_NAME = 'departments';
readonly storeName = DepartmentStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(DepartmentStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -1,40 +1,32 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* EventSerialization - Handles Date field serialization for IndexedDB
*
* IndexedDB doesn't store Date objects directly, so we convert:
* - Date ISO string (serialize) when writing to IndexedDB
* - ISO string Date (deserialize) when reading from IndexedDB
*/
export class EventSerialization {
/**
* Serialize event for IndexedDB storage
* Converts Date fields to ISO strings
*
* @param event - ICalendarEvent with Date objects
* @returns Plain object with ISO string dates
*/
static serialize(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 storage
* Converts ISO string dates back to Date objects
*
* @param data - Plain object from IndexedDB with ISO string dates
* @returns ICalendarEvent with Date objects
*/
static deserialize(data: any): ICalendarEvent {
return {
...data,
start: typeof data.start === 'string' ? new Date(data.start) : data.start,
end: typeof data.end === 'string' ? new Date(data.end) : data.end
};
}
}
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* EventSerialization - Handles Date field serialization for IndexedDB
*
* IndexedDB doesn't store Date objects directly, so we convert:
* - Date ISO string (serialize) when writing
* - ISO string Date (deserialize) when reading
*/
export class EventSerialization {
/**
* Serialize event for IndexedDB storage
*/
static serialize(event: ICalendarEvent): unknown {
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 storage
*/
static deserialize(data: Record<string, unknown>): ICalendarEvent {
return {
...data,
start: typeof data.start === 'string' ? new Date(data.start) : data.start,
end: typeof data.end === 'string' ? new Date(data.end) : data.end
} as ICalendarEvent;
}
}

View file

@ -1,174 +1,84 @@
import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes';
import { EventStore } from './EventStore';
import { EventSerialization } from './EventSerialization';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* EventService - CRUD operations for calendar events in IndexedDB
*
* 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 extends BaseEntityService<ICalendarEvent> {
readonly storeName = EventStore.STORE_NAME;
readonly entityType: EntityType = 'Event';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Serialize event for IndexedDB storage
* Converts Date objects to ISO strings
*/
protected serialize(event: ICalendarEvent): any {
return EventSerialization.serialize(event);
}
/**
* Deserialize event from IndexedDB
* Converts ISO strings back to Date objects
*/
protected deserialize(data: any): ICalendarEvent {
return EventSerialization.deserialize(data);
}
/**
* Get events within a date range
* Uses start index + in-memory filtering for simplicity and performance
*
* @param start - Start date (inclusive)
* @param end - End date (inclusive)
* @returns Array of events in range
*/
async getByDateRange(start: Date, end: Date): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
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
const range = IDBKeyRange.lowerBound(start.toISOString());
const request = index.getAll(range);
request.onsuccess = () => {
const data = request.result as any[];
// Deserialize and filter in memory
const events = data
.map(item => this.deserialize(item))
.filter(event => event.start <= end);
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events by date range: ${request.error}`));
};
});
}
/**
* Get events for a specific resource
*
* @param resourceId - Resource ID
* @returns Array of events for this resource
*/
async getByResource(resourceId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
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 => this.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for resource ${resourceId}: ${request.error}`));
};
});
}
/**
* Get events for a specific customer
*
* @param customerId - Customer ID
* @returns Array of events for this customer
*/
async getByCustomer(customerId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
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 => this.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for customer ${customerId}: ${request.error}`));
};
});
}
/**
* Get events for a specific booking
*
* @param bookingId - Booking ID
* @returns Array of events for this booking
*/
async getByBooking(bookingId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
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 => this.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for booking ${bookingId}: ${request.error}`));
};
});
}
/**
* Get events for a resource within a date range
* Combines resource and date filtering
*
* @param resourceId - Resource ID
* @param start - Start date
* @param end - End date
* @returns Array of events for this resource in range
*/
async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise<ICalendarEvent[]> {
// Get events for resource, then filter by date in memory
const resourceEvents = await this.getByResource(resourceId);
return resourceEvents.filter(event => event.start >= start && event.start <= end);
}
}
import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes';
import { EventStore } from './EventStore';
import { EventSerialization } from './EventSerialization';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* EventService - CRUD operations for calendar events in IndexedDB
*
* Extends BaseEntityService for shared CRUD and sync logic.
* Provides event-specific query methods.
*/
export class EventService extends BaseEntityService<ICalendarEvent> {
readonly storeName = EventStore.STORE_NAME;
readonly entityType: EntityType = 'Event';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
protected serialize(event: ICalendarEvent): unknown {
return EventSerialization.serialize(event);
}
protected deserialize(data: unknown): ICalendarEvent {
return EventSerialization.deserialize(data as Record<string, unknown>);
}
/**
* Get events within a date range
*/
async getByDateRange(start: Date, end: Date): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('start');
const range = IDBKeyRange.lowerBound(start.toISOString());
const request = index.getAll(range);
request.onsuccess = () => {
const data = request.result as unknown[];
const events = data
.map(item => this.deserialize(item))
.filter(event => event.start <= end);
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events by date range: ${request.error}`));
};
});
}
/**
* Get events for a specific resource
*/
async getByResource(resourceId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
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 unknown[];
const events = data.map(item => this.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for resource ${resourceId}: ${request.error}`));
};
});
}
/**
* Get events for a resource within a date range
*/
async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise<ICalendarEvent[]> {
const resourceEvents = await this.getByResource(resourceId);
return resourceEvents.filter(event => event.start >= start && event.start <= end);
}
}

View file

@ -1,47 +1,37 @@
import { IStore } from '../IStore';
/**
* EventStore - IndexedDB ObjectStore definition for calendar events
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class EventStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'events';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = EventStore.STORE_NAME;
/**
* Create the events ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(EventStore.STORE_NAME, { keyPath: 'id' });
// Index: start (for date range queries)
store.createIndex('start', 'start', { unique: false });
// Index: end (for date range queries)
store.createIndex('end', 'end', { unique: false });
// Index: syncStatus (for filtering by sync state)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
// Index: resourceId (CRITICAL for resource-mode filtering)
store.createIndex('resourceId', 'resourceId', { unique: false });
// Index: customerId (for customer-centric queries)
store.createIndex('customerId', 'customerId', { unique: false });
// Index: bookingId (for event-to-booking lookups)
store.createIndex('bookingId', 'bookingId', { unique: false });
// Compound index: startEnd (for optimized range queries)
store.createIndex('startEnd', ['start', 'end'], { unique: false });
}
}
import { IStore } from '../IStore';
/**
* EventStore - IndexedDB ObjectStore definition for calendar events
*/
export class EventStore implements IStore {
static readonly STORE_NAME = 'events';
readonly storeName = EventStore.STORE_NAME;
/**
* Create the events ObjectStore with indexes
*/
create(db: IDBDatabase): void {
const store = db.createObjectStore(EventStore.STORE_NAME, { keyPath: 'id' });
// Index: start (for date range queries)
store.createIndex('start', 'start', { unique: false });
// Index: end (for date range queries)
store.createIndex('end', 'end', { unique: false });
// Index: syncStatus (for filtering by sync state)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
// Index: resourceId (for resource-mode filtering)
store.createIndex('resourceId', 'resourceId', { unique: false });
// Index: customerId (for customer-centric queries)
store.createIndex('customerId', 'customerId', { unique: false });
// Index: bookingId (for event-to-booking lookups)
store.createIndex('bookingId', 'bookingId', { unique: false });
// Compound index: startEnd (for optimized range queries)
store.createIndex('startEnd', ['start', 'end'], { unique: false });
}
}

View file

@ -1,55 +1,55 @@
import { IResource } from '../../types/ResourceTypes';
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { ResourceStore } from './ResourceStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* ResourceService - CRUD operations for resources in IndexedDB
*
* 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)
*
* 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 extends BaseEntityService<IResource> {
readonly storeName = ResourceStore.STORE_NAME;
readonly entityType: EntityType = 'Resource';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get resources by type
*/
async getByType(type: string): Promise<IResource[]> {
const all = await this.getAll();
return all.filter(r => r.type === type);
}
/**
* Get active resources only
*/
async getActive(): Promise<IResource[]> {
const all = await this.getAll();
return all.filter(r => r.isActive === true);
}
/**
* Get inactive resources
*/
async getInactive(): Promise<IResource[]> {
const all = await this.getAll();
return all.filter(r => r.isActive === false);
}
}
import { IResource, EntityType, IEventBus } from '../../types/CalendarTypes';
import { ResourceStore } from './ResourceStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* ResourceService - CRUD operations for resources in IndexedDB
*/
export class ResourceService extends BaseEntityService<IResource> {
readonly storeName = ResourceStore.STORE_NAME;
readonly entityType: EntityType = 'Resource';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get all active resources
*/
async getActive(): Promise<IResource[]> {
const all = await this.getAll();
return all.filter(r => r.isActive !== false);
}
/**
* Get resources by IDs
*/
async getByIds(ids: string[]): Promise<IResource[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((r): r is IResource => r !== null);
}
/**
* Get resources by type
*/
async getByType(type: string): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('type');
const request = index.getAll(type);
request.onsuccess = () => {
const data = request.result as IResource[];
resolve(data);
};
request.onerror = () => {
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
};
});
}
}

View file

@ -1,26 +1,17 @@
import { IStore } from '../IStore';
/**
* ResourceStore - IndexedDB ObjectStore definition for resources
*
* Defines schema, indexes, and store creation logic.
* Part of modular storage architecture where each entity has its own folder.
*/
export class ResourceStore implements IStore {
/** ObjectStore name in IndexedDB (static for backward compatibility) */
static readonly STORE_NAME = 'resources';
/** ObjectStore name in IndexedDB (instance property for IStore interface) */
readonly storeName = ResourceStore.STORE_NAME;
/**
* Create the resources ObjectStore with indexes
* Called during database upgrade (onupgradeneeded)
*
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}
import { IStore } from '../IStore';
/**
* ResourceStore - IndexedDB ObjectStore definition for resources
*/
export class ResourceStore implements IStore {
static readonly STORE_NAME = 'resources';
readonly storeName = ResourceStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('type', 'type', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('isActive', 'isActive', { unique: false });
}
}

View file

@ -0,0 +1,84 @@
import { ITimeSlot } from '../../types/ScheduleTypes';
import { ResourceService } from '../resources/ResourceService';
import { ScheduleOverrideService } from './ScheduleOverrideService';
import { DateService } from '../../core/DateService';
/**
* ResourceScheduleService - Get effective schedule for a resource on a date
*
* Logic:
* 1. Check for override on this date
* 2. Fall back to default schedule for the weekday
*/
export class ResourceScheduleService {
constructor(
private resourceService: ResourceService,
private overrideService: ScheduleOverrideService,
private dateService: DateService
) {}
/**
* Get effective schedule for a resource on a specific date
*
* @param resourceId - Resource ID
* @param date - Date string "YYYY-MM-DD"
* @returns ITimeSlot or null (fri/closed)
*/
async getScheduleForDate(resourceId: string, date: string): Promise<ITimeSlot | null> {
// 1. Check for override
const override = await this.overrideService.getOverride(resourceId, date);
if (override) {
return override.schedule;
}
// 2. Use default schedule for weekday
const resource = await this.resourceService.get(resourceId);
if (!resource || !resource.defaultSchedule) {
return null;
}
const weekDay = this.dateService.getISOWeekDay(date);
return resource.defaultSchedule[weekDay] || null;
}
/**
* Get schedules for multiple dates
*
* @param resourceId - Resource ID
* @param dates - Array of date strings "YYYY-MM-DD"
* @returns Map of date -> ITimeSlot | null
*/
async getSchedulesForDates(resourceId: string, dates: string[]): Promise<Map<string, ITimeSlot | null>> {
const result = new Map<string, ITimeSlot | null>();
// Get resource once
const resource = await this.resourceService.get(resourceId);
// Get all overrides in date range
const overrides = dates.length > 0
? await this.overrideService.getByDateRange(resourceId, dates[0], dates[dates.length - 1])
: [];
// Build override map
const overrideMap = new Map(overrides.map(o => [o.date, o.schedule]));
// Resolve each date
for (const date of dates) {
// Check override first
if (overrideMap.has(date)) {
result.set(date, overrideMap.get(date)!);
continue;
}
// Fall back to default
if (resource?.defaultSchedule) {
const weekDay = this.dateService.getISOWeekDay(date);
result.set(date, resource.defaultSchedule[weekDay] || null);
} else {
result.set(date, null);
}
}
return result;
}
}

View file

@ -0,0 +1,100 @@
import { IScheduleOverride } from '../../types/ScheduleTypes';
import { IndexedDBContext } from '../IndexedDBContext';
import { ScheduleOverrideStore } from './ScheduleOverrideStore';
/**
* ScheduleOverrideService - CRUD for schedule overrides
*
* Provides access to date-specific schedule overrides for resources.
*/
export class ScheduleOverrideService {
private context: IndexedDBContext;
constructor(context: IndexedDBContext) {
this.context = context;
}
private get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Get override for a specific resource and date
*/
async getOverride(resourceId: string, date: string): Promise<IScheduleOverride | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const index = store.index('resourceId_date');
const request = index.get([resourceId, date]);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get override for ${resourceId} on ${date}: ${request.error}`));
};
});
}
/**
* Get all overrides for a resource
*/
async getByResource(resourceId: string): Promise<IScheduleOverride[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const index = store.index('resourceId');
const request = index.getAll(resourceId);
request.onsuccess = () => {
resolve(request.result || []);
};
request.onerror = () => {
reject(new Error(`Failed to get overrides for ${resourceId}: ${request.error}`));
};
});
}
/**
* Get overrides for a date range
*/
async getByDateRange(resourceId: string, startDate: string, endDate: string): Promise<IScheduleOverride[]> {
const all = await this.getByResource(resourceId);
return all.filter(o => o.date >= startDate && o.date <= endDate);
}
/**
* Save an override
*/
async save(override: IScheduleOverride): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const request = store.put(override);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(new Error(`Failed to save override ${override.id}: ${request.error}`));
};
});
}
/**
* Delete an override
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(new Error(`Failed to delete override ${id}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,21 @@
import { IStore } from '../IStore';
/**
* ScheduleOverrideStore - IndexedDB ObjectStore for schedule overrides
*
* Stores date-specific schedule overrides for resources.
* Indexes: resourceId, date, compound (resourceId + date)
*/
export class ScheduleOverrideStore implements IStore {
static readonly STORE_NAME = 'scheduleOverrides';
readonly storeName = ScheduleOverrideStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(ScheduleOverrideStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('resourceId', 'resourceId', { unique: false });
store.createIndex('date', 'date', { unique: false });
store.createIndex('resourceId_date', ['resourceId', 'date'], { unique: true });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -0,0 +1,83 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import {
TenantSetting,
IWorkweekSettings,
IGridSettings,
ITimeFormatSettings,
IViewSettings,
IWorkweekPreset,
SettingsIds
} from '../../types/SettingsTypes';
import { SettingsStore } from './SettingsStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* SettingsService - CRUD operations for tenant settings
*
* Settings are stored as separate records per section.
* This service provides typed methods for accessing specific settings.
*/
export class SettingsService extends BaseEntityService<TenantSetting> {
readonly storeName = SettingsStore.STORE_NAME;
readonly entityType: EntityType = 'Settings';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get workweek settings
*/
async getWorkweekSettings(): Promise<IWorkweekSettings | null> {
return this.get(SettingsIds.WORKWEEK) as Promise<IWorkweekSettings | null>;
}
/**
* Get grid settings
*/
async getGridSettings(): Promise<IGridSettings | null> {
return this.get(SettingsIds.GRID) as Promise<IGridSettings | null>;
}
/**
* Get time format settings
*/
async getTimeFormatSettings(): Promise<ITimeFormatSettings | null> {
return this.get(SettingsIds.TIME_FORMAT) as Promise<ITimeFormatSettings | null>;
}
/**
* Get view settings
*/
async getViewSettings(): Promise<IViewSettings | null> {
return this.get(SettingsIds.VIEWS) as Promise<IViewSettings | null>;
}
/**
* Get workweek preset by ID
*/
async getWorkweekPreset(presetId: string): Promise<IWorkweekPreset | null> {
const settings = await this.getWorkweekSettings();
if (!settings) return null;
return settings.presets[presetId] || null;
}
/**
* Get the default workweek preset
*/
async getDefaultWorkweekPreset(): Promise<IWorkweekPreset | null> {
const settings = await this.getWorkweekSettings();
if (!settings) return null;
return settings.presets[settings.defaultPreset] || null;
}
/**
* Get all available workweek presets
*/
async getWorkweekPresets(): Promise<IWorkweekPreset[]> {
const settings = await this.getWorkweekSettings();
if (!settings) return [];
return Object.values(settings.presets);
}
}

View file

@ -0,0 +1,16 @@
import { IStore } from '../IStore';
/**
* SettingsStore - IndexedDB ObjectStore definition for tenant settings
*
* Single store for all settings sections. Settings are stored as one document
* per tenant with id='tenant-settings'.
*/
export class SettingsStore implements IStore {
static readonly STORE_NAME = 'settings';
readonly storeName = SettingsStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(SettingsStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,44 @@
import { ITeam, EntityType, IEventBus } from '../../types/CalendarTypes';
import { TeamStore } from './TeamStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* TeamService - CRUD operations for teams in IndexedDB
*
* Teams define which resources belong together for hierarchical grouping.
* Extends BaseEntityService for standard entity operations.
*/
export class TeamService extends BaseEntityService<ITeam> {
readonly storeName = TeamStore.STORE_NAME;
readonly entityType: EntityType = 'Team';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get teams by IDs
*/
async getByIds(ids: string[]): Promise<ITeam[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((t): t is ITeam => t !== null);
}
/**
* Build reverse lookup: resourceId teamId
*/
async buildResourceToTeamMap(): Promise<Record<string, string>> {
const teams = await this.getAll();
const map: Record<string, string> = {};
for (const team of teams) {
for (const resourceId of team.resourceIds) {
map[resourceId] = team.id;
}
}
return map;
}
}

View file

@ -0,0 +1,13 @@
import { IStore } from '../IStore';
/**
* TeamStore - IndexedDB ObjectStore definition for teams
*/
export class TeamStore implements IStore {
static readonly STORE_NAME = 'teams';
readonly storeName = TeamStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(TeamStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,18 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { ViewConfig } from '../../core/ViewConfig';
import { ViewConfigStore } from './ViewConfigStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
export class ViewConfigService extends BaseEntityService<ViewConfig> {
readonly storeName = ViewConfigStore.STORE_NAME;
readonly entityType: EntityType = 'ViewConfig';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
async getById(id: string): Promise<ViewConfig | null> {
return this.get(id);
}
}

View file

@ -0,0 +1,10 @@
import { IStore } from '../IStore';
export class ViewConfigStore implements IStore {
static readonly STORE_NAME = 'viewconfigs';
readonly storeName = ViewConfigStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(ViewConfigStore.STORE_NAME, { keyPath: 'id' });
}
}