Refactors calendar data management and sync infrastructure
Introduces comprehensive data management system for calendar V2 - Adds IndexedDB storage with pluggable entity services - Implements EventBus for decoupled event communication - Creates data seeding mechanism for initial application setup - Establishes sync and repository abstractions for flexible data handling
This commit is contained in:
parent
dee977d4df
commit
e581039b62
17 changed files with 1076 additions and 4 deletions
|
|
@ -14,6 +14,24 @@ import { HeaderDrawerManager } from './core/HeaderDrawerManager';
|
|||
import { MockTeamStore, MockResourceStore } from './demo/MockStores';
|
||||
import { DemoApp } from './demo/DemoApp';
|
||||
|
||||
// Event system
|
||||
import { EventBus } from './core/EventBus';
|
||||
import { IEventBus, ICalendarEvent, ISync } from './types/CalendarTypes';
|
||||
|
||||
// Storage
|
||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||
import { IStore } from './storage/IStore';
|
||||
import { IEntityService } from './storage/IEntityService';
|
||||
import { EventStore } from './storage/events/EventStore';
|
||||
import { EventService } from './storage/events/EventService';
|
||||
|
||||
// Repositories
|
||||
import { IApiRepository } from './repositories/IApiRepository';
|
||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||
|
||||
// Workers
|
||||
import { DataSeeder } from './workers/DataSeeder';
|
||||
|
||||
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
use24HourFormat: true,
|
||||
|
|
@ -29,9 +47,29 @@ export function createV2Container(): Container {
|
|||
// Config
|
||||
builder.registerInstance(defaultTimeFormatConfig).as<ITimeFormatConfig>();
|
||||
|
||||
// Core - EventBus
|
||||
builder.registerType(EventBus).as<EventBus>();
|
||||
builder.registerType(EventBus).as<IEventBus>();
|
||||
|
||||
// Services
|
||||
builder.registerType(DateService).as<DateService>();
|
||||
|
||||
// Storage infrastructure
|
||||
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
|
||||
builder.registerType(EventStore).as<IStore>();
|
||||
|
||||
// Entity services
|
||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||
builder.registerType(EventService).as<IEntityService<ISync>>();
|
||||
builder.registerType(EventService).as<EventService>();
|
||||
|
||||
// Repositories
|
||||
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
|
||||
builder.registerType(MockEventRepository).as<IApiRepository<ISync>>();
|
||||
|
||||
// Workers
|
||||
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||
|
||||
// Renderers - registreres som IGroupingRenderer
|
||||
builder.registerType(DateRenderer).as<IGroupingRenderer>();
|
||||
builder.registerType(ResourceRenderer).as<IGroupingRenderer>();
|
||||
|
|
|
|||
47
src/v2/constants/CoreEvents.ts
Normal file
47
src/v2/constants/CoreEvents.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* CoreEvents - Consolidated essential events for the calendar V2
|
||||
*/
|
||||
export const CoreEvents = {
|
||||
// Lifecycle events
|
||||
INITIALIZED: 'core:initialized',
|
||||
READY: 'core:ready',
|
||||
DESTROYED: 'core:destroyed',
|
||||
|
||||
// View events
|
||||
VIEW_CHANGED: 'view:changed',
|
||||
VIEW_RENDERED: 'view:rendered',
|
||||
|
||||
// Navigation events
|
||||
DATE_CHANGED: 'nav:date-changed',
|
||||
NAVIGATION_COMPLETED: 'nav:navigation-completed',
|
||||
|
||||
// Data events
|
||||
DATA_LOADING: 'data:loading',
|
||||
DATA_LOADED: 'data:loaded',
|
||||
DATA_ERROR: 'data:error',
|
||||
|
||||
// Grid events
|
||||
GRID_RENDERED: 'grid:rendered',
|
||||
GRID_CLICKED: 'grid:clicked',
|
||||
|
||||
// Event management
|
||||
EVENT_CREATED: 'event:created',
|
||||
EVENT_UPDATED: 'event:updated',
|
||||
EVENT_DELETED: 'event:deleted',
|
||||
EVENT_SELECTED: 'event:selected',
|
||||
|
||||
// System events
|
||||
ERROR: 'system:error',
|
||||
|
||||
// Sync events
|
||||
SYNC_STARTED: 'sync:started',
|
||||
SYNC_COMPLETED: 'sync:completed',
|
||||
SYNC_FAILED: 'sync:failed',
|
||||
|
||||
// Entity events - for audit and sync
|
||||
ENTITY_SAVED: 'entity:saved',
|
||||
ENTITY_DELETED: 'entity:deleted',
|
||||
|
||||
// Rendering events
|
||||
EVENTS_RENDERED: 'events:rendered'
|
||||
} as const;
|
||||
174
src/v2/core/EventBus.ts
Normal file
174
src/v2/core/EventBus.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* Central event dispatcher for calendar using DOM CustomEvents
|
||||
* Provides logging and debugging capabilities
|
||||
*/
|
||||
export class EventBus implements IEventBus {
|
||||
private eventLog: IEventLogEntry[] = [];
|
||||
private debug: boolean = false;
|
||||
private listeners: Set<IListenerEntry> = new Set();
|
||||
|
||||
// Log configuration for different categories
|
||||
private logConfig: { [key: string]: boolean } = {
|
||||
calendar: true,
|
||||
grid: true,
|
||||
event: true,
|
||||
scroll: true,
|
||||
navigation: true,
|
||||
view: true,
|
||||
default: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to an event via DOM addEventListener
|
||||
*/
|
||||
on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void {
|
||||
document.addEventListener(eventType, handler, options);
|
||||
|
||||
// Track for cleanup
|
||||
this.listeners.add({ eventType, handler, options });
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => this.off(eventType, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event once
|
||||
*/
|
||||
once(eventType: string, handler: EventListener): () => void {
|
||||
return this.on(eventType, handler, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event
|
||||
*/
|
||||
off(eventType: string, handler: EventListener): void {
|
||||
document.removeEventListener(eventType, handler);
|
||||
|
||||
// Remove from tracking
|
||||
for (const listener of this.listeners) {
|
||||
if (listener.eventType === eventType && listener.handler === handler) {
|
||||
this.listeners.delete(listener);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event via DOM CustomEvent
|
||||
*/
|
||||
emit(eventType: string, detail: unknown = {}): boolean {
|
||||
// Validate eventType
|
||||
if (!eventType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const event = new CustomEvent(eventType, {
|
||||
detail: detail ?? {},
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
// Log event with grouping
|
||||
if (this.debug) {
|
||||
this.logEventWithGrouping(eventType, detail);
|
||||
}
|
||||
|
||||
this.eventLog.push({
|
||||
type: eventType,
|
||||
detail: detail ?? {},
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Emit on document (only DOM events now)
|
||||
return !document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event with console grouping
|
||||
*/
|
||||
private logEventWithGrouping(eventType: string, _detail: unknown): void {
|
||||
// Extract category from event type (e.g., 'calendar:datechanged' → 'calendar')
|
||||
const category = this.extractCategory(eventType);
|
||||
|
||||
// Only log if category is enabled
|
||||
if (!this.logConfig[category]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get category emoji and color (used for future console styling)
|
||||
this.getCategoryStyle(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract category from event type
|
||||
*/
|
||||
private extractCategory(eventType: string): string {
|
||||
if (!eventType) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (eventType.includes(':')) {
|
||||
return eventType.split(':')[0];
|
||||
}
|
||||
|
||||
// Fallback: try to detect category from event name patterns
|
||||
const lowerType = eventType.toLowerCase();
|
||||
if (lowerType.includes('grid') || lowerType.includes('rendered')) return 'grid';
|
||||
if (lowerType.includes('event') || lowerType.includes('sync')) return 'event';
|
||||
if (lowerType.includes('scroll')) return 'scroll';
|
||||
if (lowerType.includes('nav') || lowerType.includes('date')) return 'navigation';
|
||||
if (lowerType.includes('view')) return 'view';
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styling for different categories
|
||||
*/
|
||||
private getCategoryStyle(category: string): { emoji: string; color: string } {
|
||||
const styles: { [key: string]: { emoji: string; color: string } } = {
|
||||
calendar: { emoji: '📅', color: '#2196F3' },
|
||||
grid: { emoji: '📊', color: '#4CAF50' },
|
||||
event: { emoji: '📌', color: '#FF9800' },
|
||||
scroll: { emoji: '📜', color: '#9C27B0' },
|
||||
navigation: { emoji: '🧭', color: '#F44336' },
|
||||
view: { emoji: '👁', color: '#00BCD4' },
|
||||
default: { emoji: '📢', color: '#607D8B' }
|
||||
};
|
||||
|
||||
return styles[category] || styles.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure logging for specific categories
|
||||
*/
|
||||
setLogConfig(config: { [key: string]: boolean }): void {
|
||||
this.logConfig = { ...this.logConfig, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current log configuration
|
||||
*/
|
||||
getLogConfig(): { [key: string]: boolean } {
|
||||
return { ...this.logConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event history
|
||||
*/
|
||||
getEventLog(eventType?: string): IEventLogEntry[] {
|
||||
if (eventType) {
|
||||
return this.eventLog.filter(e => e.type === eventType);
|
||||
}
|
||||
return this.eventLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable debug mode
|
||||
*/
|
||||
setDebug(enabled: boolean): void {
|
||||
this.debug = enabled;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { DateService } from '../core/DateService';
|
|||
import { ScrollManager } from '../core/ScrollManager';
|
||||
import { HeaderDrawerManager } from '../core/HeaderDrawerManager';
|
||||
import { ViewConfig } from '../core/ViewConfig';
|
||||
import { IndexedDBContext } from '../storage/IndexedDBContext';
|
||||
import { DataSeeder } from '../workers/DataSeeder';
|
||||
|
||||
export class DemoApp {
|
||||
private animator!: NavigationAnimator;
|
||||
|
|
@ -17,10 +19,20 @@ export class DemoApp {
|
|||
private timeAxisRenderer: TimeAxisRenderer,
|
||||
private dateService: DateService,
|
||||
private scrollManager: ScrollManager,
|
||||
private headerDrawerManager: HeaderDrawerManager
|
||||
private headerDrawerManager: HeaderDrawerManager,
|
||||
private indexedDBContext: IndexedDBContext,
|
||||
private dataSeeder: DataSeeder
|
||||
) {}
|
||||
|
||||
init(): void {
|
||||
async init(): Promise<void> {
|
||||
// Initialize IndexedDB
|
||||
await this.indexedDBContext.initialize();
|
||||
console.log('[DemoApp] IndexedDB initialized');
|
||||
|
||||
// Seed data if empty
|
||||
await this.dataSeeder.seedIfEmpty();
|
||||
console.log('[DemoApp] Data seeding complete');
|
||||
|
||||
this.container = document.querySelector('swp-calendar-container') as HTMLElement;
|
||||
|
||||
// NavigationAnimator har DOM-dependencies - tilladt med new
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createV2Container } from '../V2CompositionRoot';
|
||||
import { DemoApp } from './DemoApp';
|
||||
|
||||
const app = createV2Container();
|
||||
app.resolveType<DemoApp>().init();
|
||||
const container = createV2Container();
|
||||
container.resolveType<DemoApp>().init().catch(console.error);
|
||||
|
|
|
|||
33
src/v2/repositories/IApiRepository.ts
Normal file
33
src/v2/repositories/IApiRepository.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { EntityType } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* IApiRepository<T> - Generic interface for backend API communication
|
||||
*
|
||||
* Used by DataSeeder to fetch initial data and by SyncManager for sync operations.
|
||||
*/
|
||||
export interface IApiRepository<T> {
|
||||
/**
|
||||
* Entity type discriminator - used for runtime routing
|
||||
*/
|
||||
readonly entityType: EntityType;
|
||||
|
||||
/**
|
||||
* Send create operation to backend API
|
||||
*/
|
||||
sendCreate(data: T): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send update operation to backend API
|
||||
*/
|
||||
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send delete operation to backend API
|
||||
*/
|
||||
sendDelete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetch all entities from backend API
|
||||
*/
|
||||
fetchAll(): Promise<T[]>;
|
||||
}
|
||||
86
src/v2/repositories/MockEventRepository.ts
Normal file
86
src/v2/repositories/MockEventRepository.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { ICalendarEvent, EntityType, CalendarEventType } from '../types/CalendarTypes';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
interface RawEventData {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string | Date;
|
||||
end: string | Date;
|
||||
type: string;
|
||||
allDay?: boolean;
|
||||
bookingId?: string;
|
||||
resourceId?: string;
|
||||
customerId?: string;
|
||||
description?: string;
|
||||
recurringId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* MockEventRepository - Loads event data from local JSON file
|
||||
*
|
||||
* Used for development and testing. Only fetchAll() is implemented.
|
||||
*/
|
||||
export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
||||
public readonly entityType: EntityType = 'Event';
|
||||
private readonly dataUrl = 'data/mock-events.json';
|
||||
|
||||
/**
|
||||
* Fetch all events from mock JSON file
|
||||
*/
|
||||
public async fetchAll(): Promise<ICalendarEvent[]> {
|
||||
try {
|
||||
const response = await fetch(this.dataUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load mock events: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const rawData: RawEventData[] = await response.json();
|
||||
return this.processCalendarData(rawData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load event data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async sendCreate(_event: ICalendarEvent): Promise<ICalendarEvent> {
|
||||
throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
public async sendUpdate(_id: string, _updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
|
||||
throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
public async sendDelete(_id: string): Promise<void> {
|
||||
throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.');
|
||||
}
|
||||
|
||||
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
||||
return data.map((event): ICalendarEvent => {
|
||||
// Validate customer event constraints
|
||||
if (event.type === 'customer') {
|
||||
if (!event.bookingId) console.warn(`Customer event ${event.id} missing bookingId`);
|
||||
if (!event.resourceId) console.warn(`Customer event ${event.id} missing resourceId`);
|
||||
if (!event.customerId) console.warn(`Customer event ${event.id} missing customerId`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end),
|
||||
type: event.type as CalendarEventType,
|
||||
allDay: event.allDay || false,
|
||||
bookingId: event.bookingId,
|
||||
resourceId: event.resourceId,
|
||||
customerId: event.customerId,
|
||||
recurringId: event.recurringId,
|
||||
metadata: event.metadata,
|
||||
syncStatus: 'synced' as const
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
158
src/v2/storage/BaseEntityService.ts
Normal file
158
src/v2/storage/BaseEntityService.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
async save(entity: T): Promise<void> {
|
||||
const entityId = (entity as unknown as { id: string }).id;
|
||||
const existingEntity = await this.get(entityId);
|
||||
const isNew = existingEntity === null;
|
||||
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 = () => {
|
||||
const payload: IEntitySavedPayload = {
|
||||
entityType: this.entityType,
|
||||
entity,
|
||||
isNew
|
||||
};
|
||||
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entity
|
||||
*/
|
||||
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,
|
||||
id
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
38
src/v2/storage/IEntityService.ts
Normal file
38
src/v2/storage/IEntityService.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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
|
||||
*/
|
||||
save(entity: T): 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>;
|
||||
}
|
||||
18
src/v2/storage/IStore.ts
Normal file
18
src/v2/storage/IStore.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
92
src/v2/storage/IndexedDBContext.ts
Normal file
92
src/v2/storage/IndexedDBContext.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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 = 'CalendarV2DB';
|
||||
private static readonly DB_VERSION = 1;
|
||||
|
||||
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}`));
|
||||
});
|
||||
}
|
||||
}
|
||||
64
src/v2/storage/SyncPlugin.ts
Normal file
64
src/v2/storage/SyncPlugin.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/v2/storage/events/EventSerialization.ts
Normal file
32
src/v2/storage/events/EventSerialization.ts
Normal file
|
|
@ -0,0 +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
|
||||
* - 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;
|
||||
}
|
||||
}
|
||||
84
src/v2/storage/events/EventService.ts
Normal file
84
src/v2/storage/events/EventService.ts
Normal file
|
|
@ -0,0 +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
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
37
src/v2/storage/events/EventStore.ts
Normal file
37
src/v2/storage/events/EventStore.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
86
src/v2/types/CalendarTypes.ts
Normal file
86
src/v2/types/CalendarTypes.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Calendar V2 Type Definitions
|
||||
*/
|
||||
|
||||
export type SyncStatus = 'synced' | 'pending' | 'error';
|
||||
|
||||
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit';
|
||||
|
||||
/**
|
||||
* CalendarEventType - Used by ICalendarEvent.type
|
||||
* Note: Only 'customer' events have associated IBooking
|
||||
*/
|
||||
export type CalendarEventType =
|
||||
| 'customer' // Customer appointment (HAS booking)
|
||||
| 'vacation' // Vacation/time off (NO booking)
|
||||
| 'break' // Lunch/break (NO booking)
|
||||
| 'meeting' // Meeting (NO booking)
|
||||
| 'blocked'; // Blocked time (NO booking)
|
||||
|
||||
/**
|
||||
* ISync - Interface for sync status tracking
|
||||
* All syncable entities should extend this interface
|
||||
*/
|
||||
export interface ISync {
|
||||
syncStatus: SyncStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDataEntity - Wrapper for entity data with typename discriminator
|
||||
*/
|
||||
export interface IDataEntity {
|
||||
typename: EntityType;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface ICalendarEvent extends ISync {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: CalendarEventType;
|
||||
allDay: boolean;
|
||||
|
||||
// References (denormalized for IndexedDB performance)
|
||||
bookingId?: string; // Reference to booking (only if type = 'customer')
|
||||
resourceId?: string; // Resource who owns this time slot
|
||||
customerId?: string; // Denormalized from Booking.customerId
|
||||
|
||||
recurringId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// EventBus types
|
||||
export interface IEventLogEntry {
|
||||
type: string;
|
||||
detail: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IListenerEntry {
|
||||
eventType: string;
|
||||
handler: EventListener;
|
||||
options?: AddEventListenerOptions;
|
||||
}
|
||||
|
||||
export interface IEventBus {
|
||||
on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void;
|
||||
once(eventType: string, handler: EventListener): () => void;
|
||||
off(eventType: string, handler: EventListener): void;
|
||||
emit(eventType: string, detail?: unknown): boolean;
|
||||
getEventLog(eventType?: string): IEventLogEntry[];
|
||||
setDebug(enabled: boolean): void;
|
||||
}
|
||||
|
||||
// Entity event payloads
|
||||
export interface IEntitySavedPayload {
|
||||
entityType: EntityType;
|
||||
entity: ISync;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
export interface IEntityDeletedPayload {
|
||||
entityType: EntityType;
|
||||
id: string;
|
||||
}
|
||||
73
src/v2/workers/DataSeeder.ts
Normal file
73
src/v2/workers/DataSeeder.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { IApiRepository } from '../repositories/IApiRepository';
|
||||
import { IEntityService } from '../storage/IEntityService';
|
||||
import { ISync } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* DataSeeder - Orchestrates initial data loading from repositories into IndexedDB
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - Repository (Mock/Api): Fetches data from source (JSON file or backend API)
|
||||
* - DataSeeder (this class): Orchestrates fetch + save operations
|
||||
* - Service (EventService, etc.): Saves data to IndexedDB
|
||||
*
|
||||
* POLYMORPHIC DESIGN:
|
||||
* - Uses arrays of IEntityService[] and IApiRepository[]
|
||||
* - Matches services with repositories using entityType property
|
||||
* - Open/Closed Principle: Adding new entity requires no code changes here
|
||||
*/
|
||||
export class DataSeeder {
|
||||
constructor(
|
||||
private services: IEntityService<ISync>[],
|
||||
private repositories: IApiRepository<ISync>[]
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Seed all entity stores if they are empty
|
||||
*/
|
||||
async seedIfEmpty(): Promise<void> {
|
||||
console.log('[DataSeeder] Checking if database needs seeding...');
|
||||
|
||||
try {
|
||||
for (const service of this.services) {
|
||||
const repository = this.repositories.find(repo => repo.entityType === service.entityType);
|
||||
|
||||
if (!repository) {
|
||||
console.warn(`[DataSeeder] No repository found for entity type: ${service.entityType}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.seedEntity(service.entityType, service, repository);
|
||||
}
|
||||
|
||||
console.log('[DataSeeder] Seeding complete');
|
||||
} catch (error) {
|
||||
console.error('[DataSeeder] Seeding failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async seedEntity<T extends ISync>(
|
||||
entityType: string,
|
||||
service: IEntityService<T>,
|
||||
repository: IApiRepository<T>
|
||||
): Promise<void> {
|
||||
const existing = await service.getAll();
|
||||
|
||||
if (existing.length > 0) {
|
||||
console.log(`[DataSeeder] ${entityType} store already has ${existing.length} items, skipping seed`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DataSeeder] ${entityType} store is empty, fetching from repository...`);
|
||||
|
||||
const data = await repository.fetchAll();
|
||||
|
||||
console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`);
|
||||
|
||||
for (const entity of data) {
|
||||
await service.save(entity);
|
||||
}
|
||||
|
||||
console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue