Enhances event layout engine with advanced rendering logic

Introduces sophisticated event layout algorithm for handling complex scheduling scenarios

Adds support for:
- Grid and stacked event rendering
- Automatic column allocation
- Nested event stacking
- Threshold-based event grouping

Improves visual representation of overlapping and concurrent events
This commit is contained in:
Janus C. H. Knudsen 2025-12-11 18:11:11 +01:00
parent 4e22fbc948
commit 70172e8f10
26 changed files with 2108 additions and 44 deletions

View file

@ -3,6 +3,7 @@ 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
@ -87,11 +88,25 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
/**
* 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): Promise<void> {
async save(entity: T, silent = false): Promise<void> {
const entityId = (entity as unknown as { id: string }).id;
const existingEntity = await this.get(entityId);
const isNew = existingEntity === null;
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) => {
@ -100,12 +115,17 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
const request = store.put(serialized);
request.onsuccess = () => {
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entity,
isNew
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
// 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();
};
@ -117,6 +137,7 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
/**
* Delete an entity
* Emits ENTITY_DELETED event
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
@ -127,7 +148,9 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
request.onsuccess = () => {
const payload: IEntityDeletedPayload = {
entityType: this.entityType,
id
entityId: id,
operation: 'delete',
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
resolve();

View file

@ -18,8 +18,10 @@ export interface IEntityService<T extends ISync> {
/**
* 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): Promise<void>;
save(entity: T, silent?: boolean): Promise<void>;
/**
* Mark entity as successfully synced

View file

@ -0,0 +1,167 @@
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
import { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes';
import { EntityType, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../../types/CalendarTypes';
import { CoreEvents } from '../../constants/CoreEvents';
/**
* AuditService - Entity service for audit entries
*
* RESPONSIBILITIES:
* - Store audit entries in IndexedDB
* - Listen for ENTITY_SAVED/ENTITY_DELETED events
* - Create audit entries for all entity changes
* - Emit AUDIT_LOGGED after saving (for SyncManager to listen)
*
* OVERRIDE PATTERN:
* - Overrides save() to NOT emit events (prevents infinite loops)
* - AuditService saves audit entries without triggering more audits
*
* EVENT CHAIN:
* Entity change ENTITY_SAVED/DELETED AuditService AUDIT_LOGGED SyncManager
*/
export class AuditService extends BaseEntityService<IAuditEntry> {
readonly storeName = 'audit';
readonly entityType: EntityType = 'Audit';
// Hardcoded userId for now - will come from session later
private static readonly DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
this.setupEventListeners();
}
/**
* Setup listeners for ENTITY_SAVED and ENTITY_DELETED events
*/
private setupEventListeners(): void {
// Listen for entity saves (create/update)
this.eventBus.on(CoreEvents.ENTITY_SAVED, (event: Event) => {
const detail = (event as CustomEvent).detail;
this.handleEntitySaved(detail);
});
// Listen for entity deletes
this.eventBus.on(CoreEvents.ENTITY_DELETED, (event: Event) => {
const detail = (event as CustomEvent).detail;
this.handleEntityDeleted(detail);
});
}
/**
* Handle ENTITY_SAVED event - create audit entry
*/
private async handleEntitySaved(payload: IEntitySavedPayload): Promise<void> {
// Don't audit audit entries (prevent infinite loops)
if (payload.entityType === 'Audit') return;
const auditEntry: IAuditEntry = {
id: crypto.randomUUID(),
entityType: payload.entityType,
entityId: payload.entityId,
operation: payload.operation,
userId: AuditService.DEFAULT_USER_ID,
timestamp: payload.timestamp,
changes: payload.changes,
synced: false,
syncStatus: 'pending'
};
await this.save(auditEntry);
}
/**
* Handle ENTITY_DELETED event - create audit entry
*/
private async handleEntityDeleted(payload: IEntityDeletedPayload): Promise<void> {
// Don't audit audit entries (prevent infinite loops)
if (payload.entityType === 'Audit') return;
const auditEntry: IAuditEntry = {
id: crypto.randomUUID(),
entityType: payload.entityType,
entityId: payload.entityId,
operation: 'delete',
userId: AuditService.DEFAULT_USER_ID,
timestamp: payload.timestamp,
changes: { id: payload.entityId }, // For delete, just store the ID
synced: false,
syncStatus: 'pending'
};
await this.save(auditEntry);
}
/**
* Override save to NOT trigger ENTITY_SAVED event
* Instead, emits AUDIT_LOGGED for SyncManager to listen
*
* This prevents infinite loops:
* - BaseEntityService.save() emits ENTITY_SAVED
* - AuditService listens to ENTITY_SAVED and creates audit
* - If AuditService.save() also emitted ENTITY_SAVED, it would loop
*/
async save(entity: IAuditEntry): Promise<void> {
const serialized = this.serialize(entity);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(serialized);
request.onsuccess = () => {
// Emit AUDIT_LOGGED instead of ENTITY_SAVED
const payload: IAuditLoggedPayload = {
auditId: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
operation: entity.operation,
timestamp: entity.timestamp
};
this.eventBus.emit(CoreEvents.AUDIT_LOGGED, payload);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save audit entry ${entity.id}: ${request.error}`));
};
});
}
/**
* Override delete to NOT trigger ENTITY_DELETED event
* Audit entries should never be deleted (compliance requirement)
*/
async delete(_id: string): Promise<void> {
throw new Error('Audit entries cannot be deleted (compliance requirement)');
}
/**
* Get pending audit entries (for sync)
*/
async getPendingAudits(): Promise<IAuditEntry[]> {
return this.getBySyncStatus('pending');
}
/**
* Get audit entries for a specific entity
*/
async getByEntityId(entityId: string): Promise<IAuditEntry[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('entityId');
const request = index.getAll(entityId);
request.onsuccess = () => {
const entries = request.result as IAuditEntry[];
resolve(entries);
};
request.onerror = () => {
reject(new Error(`Failed to get audit entries for entity ${entityId}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,27 @@
import { IStore } from '../IStore';
/**
* AuditStore - IndexedDB store configuration for audit entries
*
* Stores all entity changes for:
* - Compliance and audit trail
* - Sync tracking with backend
* - Change history
*
* 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';
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 });
}
}