Moving away from Azure Devops #1
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 { MockTeamStore, MockResourceStore } from './demo/MockStores';
|
||||||
import { DemoApp } from './demo/DemoApp';
|
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 = {
|
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
use24HourFormat: true,
|
use24HourFormat: true,
|
||||||
|
|
@ -29,9 +47,29 @@ export function createV2Container(): Container {
|
||||||
// Config
|
// Config
|
||||||
builder.registerInstance(defaultTimeFormatConfig).as<ITimeFormatConfig>();
|
builder.registerInstance(defaultTimeFormatConfig).as<ITimeFormatConfig>();
|
||||||
|
|
||||||
|
// Core - EventBus
|
||||||
|
builder.registerType(EventBus).as<EventBus>();
|
||||||
|
builder.registerType(EventBus).as<IEventBus>();
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
builder.registerType(DateService).as<DateService>();
|
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
|
// Renderers - registreres som IGroupingRenderer
|
||||||
builder.registerType(DateRenderer).as<IGroupingRenderer>();
|
builder.registerType(DateRenderer).as<IGroupingRenderer>();
|
||||||
builder.registerType(ResourceRenderer).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 { ScrollManager } from '../core/ScrollManager';
|
||||||
import { HeaderDrawerManager } from '../core/HeaderDrawerManager';
|
import { HeaderDrawerManager } from '../core/HeaderDrawerManager';
|
||||||
import { ViewConfig } from '../core/ViewConfig';
|
import { ViewConfig } from '../core/ViewConfig';
|
||||||
|
import { IndexedDBContext } from '../storage/IndexedDBContext';
|
||||||
|
import { DataSeeder } from '../workers/DataSeeder';
|
||||||
|
|
||||||
export class DemoApp {
|
export class DemoApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
|
|
@ -17,10 +19,20 @@ export class DemoApp {
|
||||||
private timeAxisRenderer: TimeAxisRenderer,
|
private timeAxisRenderer: TimeAxisRenderer,
|
||||||
private dateService: DateService,
|
private dateService: DateService,
|
||||||
private scrollManager: ScrollManager,
|
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;
|
this.container = document.querySelector('swp-calendar-container') as HTMLElement;
|
||||||
|
|
||||||
// NavigationAnimator har DOM-dependencies - tilladt med new
|
// NavigationAnimator har DOM-dependencies - tilladt med new
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createV2Container } from '../V2CompositionRoot';
|
import { createV2Container } from '../V2CompositionRoot';
|
||||||
import { DemoApp } from './DemoApp';
|
import { DemoApp } from './DemoApp';
|
||||||
|
|
||||||
const app = createV2Container();
|
const container = createV2Container();
|
||||||
app.resolveType<DemoApp>().init();
|
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