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:
parent
4e22fbc948
commit
70172e8f10
26 changed files with 2108 additions and 44 deletions
|
|
@ -31,12 +31,18 @@ import { BookingService } from './storage/bookings/BookingService';
|
|||
import { CustomerStore } from './storage/customers/CustomerStore';
|
||||
import { CustomerService } from './storage/customers/CustomerService';
|
||||
|
||||
// Audit
|
||||
import { AuditStore } from './storage/audit/AuditStore';
|
||||
import { AuditService } from './storage/audit/AuditService';
|
||||
import { IAuditEntry } from './types/AuditTypes';
|
||||
|
||||
// Repositories
|
||||
import { IApiRepository } from './repositories/IApiRepository';
|
||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||
|
||||
// Workers
|
||||
import { DataSeeder } from './workers/DataSeeder';
|
||||
|
|
@ -55,6 +61,7 @@ import { ResourceScheduleService } from './storage/schedules/ResourceScheduleSer
|
|||
import { DragDropManager } from './managers/DragDropManager';
|
||||
import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
||||
import { ResizeManager } from './managers/ResizeManager';
|
||||
import { EventPersistenceManager } from './managers/EventPersistenceManager';
|
||||
|
||||
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
|
|
@ -68,7 +75,8 @@ const defaultGridConfig: IGridConfig = {
|
|||
hourHeight: 64,
|
||||
dayStartHour: 6,
|
||||
dayEndHour: 18,
|
||||
snapInterval: 15
|
||||
snapInterval: 15,
|
||||
gridStartThresholdMinutes: 30
|
||||
};
|
||||
|
||||
export function createV2Container(): Container {
|
||||
|
|
@ -95,6 +103,7 @@ export function createV2Container(): Container {
|
|||
builder.registerType(BookingStore).as<IStore>();
|
||||
builder.registerType(CustomerStore).as<IStore>();
|
||||
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
||||
builder.registerType(AuditStore).as<IStore>();
|
||||
|
||||
// Entity services (for DataSeeder polymorphic array)
|
||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||
|
|
@ -126,6 +135,12 @@ export function createV2Container(): Container {
|
|||
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
|
||||
builder.registerType(MockCustomerRepository).as<IApiRepository<ISync>>();
|
||||
|
||||
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
|
||||
builder.registerType(MockAuditRepository).as<IApiRepository<ISync>>();
|
||||
|
||||
// Audit service (listens to ENTITY_SAVED/DELETED events automatically)
|
||||
builder.registerType(AuditService).as<AuditService>();
|
||||
|
||||
// Workers
|
||||
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||
|
||||
|
|
@ -155,6 +170,7 @@ export function createV2Container(): Container {
|
|||
builder.registerType(DragDropManager).as<DragDropManager>();
|
||||
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
|
||||
builder.registerType(ResizeManager).as<ResizeManager>();
|
||||
builder.registerType(EventPersistenceManager).as<EventPersistenceManager>();
|
||||
|
||||
// Demo app
|
||||
builder.registerType(DemoApp).as<DemoApp>();
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ export const CoreEvents = {
|
|||
ENTITY_SAVED: 'entity:saved',
|
||||
ENTITY_DELETED: 'entity:deleted',
|
||||
|
||||
// Audit events
|
||||
AUDIT_LOGGED: 'audit:logged',
|
||||
|
||||
// Rendering events
|
||||
EVENTS_RENDERED: 'events:rendered'
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ export interface IGridConfig {
|
|||
dayStartHour: number; // e.g. 6
|
||||
dayEndHour: number; // e.g. 18
|
||||
snapInterval: number; // minutes, e.g. 15
|
||||
gridStartThresholdMinutes?: number; // threshold for GRID grouping (default 10)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { ViewConfig } from '../core/ViewConfig';
|
|||
import { DragDropManager } from '../managers/DragDropManager';
|
||||
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
|
||||
import { ResizeManager } from '../managers/ResizeManager';
|
||||
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||
import { AuditService } from '../storage/audit/AuditService';
|
||||
|
||||
export class DemoApp {
|
||||
private animator!: NavigationAnimator;
|
||||
|
|
@ -29,7 +31,9 @@ export class DemoApp {
|
|||
private dragDropManager: DragDropManager,
|
||||
private edgeScrollManager: EdgeScrollManager,
|
||||
private resizeManager: ResizeManager,
|
||||
private headerDrawerRenderer: HeaderDrawerRenderer
|
||||
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||
private eventPersistenceManager: EventPersistenceManager,
|
||||
private auditService: AuditService
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
|
|
|
|||
279
src/v2/features/event/EventLayoutEngine.ts
Normal file
279
src/v2/features/event/EventLayoutEngine.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* EventLayoutEngine - Simplified stacking/grouping algorithm for V2
|
||||
*
|
||||
* Supports two layout modes:
|
||||
* - GRID: Events starting at same time rendered side-by-side
|
||||
* - STACKING: Overlapping events with margin-left offset (15px per level)
|
||||
*
|
||||
* Simplified from V1: No prev/next chains, single-pass greedy algorithm
|
||||
*/
|
||||
|
||||
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||
import { IGridConfig } from '../../core/IGridConfig';
|
||||
import { calculateEventPosition } from '../../utils/PositionUtils';
|
||||
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
|
||||
|
||||
/**
|
||||
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
|
||||
* This matches Scenario 8: end===start is NOT overlap
|
||||
*/
|
||||
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
|
||||
return a.start < b.end && a.end > b.start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two events are within threshold for grid grouping.
|
||||
* This includes:
|
||||
* 1. Start-to-start: Events start within threshold of each other
|
||||
* 2. End-to-start: One event starts within threshold before another ends
|
||||
*/
|
||||
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
|
||||
const thresholdMs = thresholdMinutes * 60 * 1000;
|
||||
|
||||
// Start-to-start: both events start within threshold
|
||||
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
|
||||
if (startToStartDiff <= thresholdMs) return true;
|
||||
|
||||
// End-to-start: one event starts within threshold before the other ends
|
||||
// B starts within threshold before A ends
|
||||
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
|
||||
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
|
||||
|
||||
// A starts within threshold before B ends
|
||||
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
|
||||
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all events in a group start within threshold of each other
|
||||
*/
|
||||
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
|
||||
if (events.length <= 1) return true;
|
||||
|
||||
// Find earliest and latest start times
|
||||
let earliest = events[0].start.getTime();
|
||||
let latest = events[0].start.getTime();
|
||||
|
||||
for (const event of events) {
|
||||
const time = event.start.getTime();
|
||||
if (time < earliest) earliest = time;
|
||||
if (time > latest) latest = time;
|
||||
}
|
||||
|
||||
const diffMinutes = (latest - earliest) / (1000 * 60);
|
||||
return diffMinutes <= thresholdMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find groups of overlapping events (connected by overlap chain)
|
||||
* Events are grouped if they overlap with any event in the group
|
||||
*/
|
||||
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
const used = new Set<string>();
|
||||
const groups: ICalendarEvent[][] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
if (used.has(event.id)) continue;
|
||||
|
||||
// Start a new group with this event
|
||||
const group: ICalendarEvent[] = [event];
|
||||
used.add(event.id);
|
||||
|
||||
// Expand group by finding all connected events (via overlap)
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const candidate of sorted) {
|
||||
if (used.has(candidate.id)) continue;
|
||||
|
||||
// Check if candidate overlaps with any event in group
|
||||
const connects = group.some(member => eventsOverlap(member, candidate));
|
||||
|
||||
if (connects) {
|
||||
group.push(candidate);
|
||||
used.add(candidate.id);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find grid candidates within a group - events connected via threshold chain
|
||||
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
|
||||
*/
|
||||
function findGridCandidates(
|
||||
events: ICalendarEvent[],
|
||||
thresholdMinutes: number
|
||||
): ICalendarEvent[][] {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
const used = new Set<string>();
|
||||
const groups: ICalendarEvent[][] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
if (used.has(event.id)) continue;
|
||||
|
||||
const group: ICalendarEvent[] = [event];
|
||||
used.add(event.id);
|
||||
|
||||
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const candidate of sorted) {
|
||||
if (used.has(candidate.id)) continue;
|
||||
|
||||
const connects = group.some(member =>
|
||||
eventsWithinThreshold(member, candidate, thresholdMinutes)
|
||||
);
|
||||
|
||||
if (connects) {
|
||||
group.push(candidate);
|
||||
used.add(candidate.id);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stack levels for overlapping events using greedy algorithm
|
||||
* For each event: level = max(overlapping already-processed events) + 1
|
||||
*/
|
||||
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
|
||||
const levels = new Map<string, number>();
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
for (const event of sorted) {
|
||||
let maxOverlappingLevel = -1;
|
||||
|
||||
// Find max level among overlapping events already processed
|
||||
for (const [id, level] of levels) {
|
||||
const other = events.find(e => e.id === id);
|
||||
if (other && eventsOverlap(event, other)) {
|
||||
maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
|
||||
}
|
||||
}
|
||||
|
||||
levels.set(event.id, maxOverlappingLevel + 1);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate events to columns for GRID layout using greedy algorithm
|
||||
* Non-overlapping events can share a column to minimize total columns
|
||||
*/
|
||||
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
const columns: ICalendarEvent[][] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
// Find first column where event doesn't overlap with existing events
|
||||
let placed = false;
|
||||
for (const column of columns) {
|
||||
const canFit = !column.some(e => eventsOverlap(event, e));
|
||||
if (canFit) {
|
||||
column.push(event);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No suitable column found, create new one
|
||||
if (!placed) {
|
||||
columns.push([event]);
|
||||
}
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: Calculate complete layout for a column's events
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Find overlap groups (events connected by overlap chain)
|
||||
* 2. For each overlap group, find grid candidates (events within threshold chain)
|
||||
* 3. If all events in overlap group form a single grid candidate → GRID mode
|
||||
* 4. Otherwise → STACKING mode with calculated levels
|
||||
*/
|
||||
export function calculateColumnLayout(
|
||||
events: ICalendarEvent[],
|
||||
config: IGridConfig
|
||||
): IColumnLayout {
|
||||
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
|
||||
|
||||
const result: IColumnLayout = {
|
||||
grids: [],
|
||||
stacked: []
|
||||
};
|
||||
|
||||
if (events.length === 0) return result;
|
||||
|
||||
// Find all overlapping event groups
|
||||
const overlapGroups = findOverlapGroups(events);
|
||||
|
||||
for (const overlapGroup of overlapGroups) {
|
||||
if (overlapGroup.length === 1) {
|
||||
// Single event - no grouping needed
|
||||
result.stacked.push({
|
||||
event: overlapGroup[0],
|
||||
stackLevel: 0
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Within this overlap group, find grid candidates (threshold-connected subgroups)
|
||||
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
|
||||
|
||||
// Check if the ENTIRE overlap group forms a single grid candidate
|
||||
// This happens when all events are connected via threshold chain
|
||||
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
|
||||
g.length > max.length ? g : max, gridSubgroups[0]);
|
||||
|
||||
if (largestGridCandidate.length === overlapGroup.length) {
|
||||
// All events in overlap group are connected via threshold chain → GRID mode
|
||||
const columns = allocateColumns(overlapGroup);
|
||||
const earliest = overlapGroup.reduce((min, e) =>
|
||||
e.start < min.start ? e : min, overlapGroup[0]);
|
||||
const position = calculateEventPosition(earliest.start, earliest.end, config);
|
||||
|
||||
result.grids.push({
|
||||
events: overlapGroup,
|
||||
columns,
|
||||
stackLevel: 0,
|
||||
position: { top: position.top }
|
||||
});
|
||||
} else {
|
||||
// Not all events connected via threshold → STACKING mode
|
||||
const levels = calculateStackLevels(overlapGroup);
|
||||
for (const event of overlapGroup) {
|
||||
result.stacked.push({
|
||||
event,
|
||||
stackLevel: levels.get(event.id) ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
35
src/v2/features/event/EventLayoutTypes.ts
Normal file
35
src/v2/features/event/EventLayoutTypes.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* Stack link metadata stored on event elements
|
||||
* Simplified from V1: No prev/next chains - only stackLevel needed for rendering
|
||||
*/
|
||||
export interface IStackLink {
|
||||
stackLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout result for a stacked event (overlapping events with margin offset)
|
||||
*/
|
||||
export interface IStackedEventLayout {
|
||||
event: ICalendarEvent;
|
||||
stackLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout result for a grid group (simultaneous events side-by-side)
|
||||
*/
|
||||
export interface IGridGroupLayout {
|
||||
events: ICalendarEvent[];
|
||||
columns: ICalendarEvent[][]; // Events grouped by column (non-overlapping within column)
|
||||
stackLevel: number; // Stack level for entire group (if nested in another event)
|
||||
position: { top: number }; // Top position of earliest event in pixels
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete layout result for a column's events
|
||||
*/
|
||||
export interface IColumnLayout {
|
||||
grids: IGridGroupLayout[];
|
||||
stacked: IStackedEventLayout[];
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { ICalendarEvent, IEventBus } from '../../types/CalendarTypes';
|
||||
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
|
||||
import { EventService } from '../../storage/events/EventService';
|
||||
import { DateService } from '../../core/DateService';
|
||||
import { IGridConfig } from '../../core/IGridConfig';
|
||||
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
|
||||
import { CoreEvents } from '../../constants/CoreEvents';
|
||||
import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragTypes';
|
||||
import { calculateColumnLayout } from './EventLayoutEngine';
|
||||
import { IGridGroupLayout } from './EventLayoutTypes';
|
||||
|
||||
/**
|
||||
* EventRenderer - Renders calendar events to the DOM
|
||||
|
|
@ -15,19 +17,21 @@ import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragType
|
|||
* - Event data retrieved via EventService when needed
|
||||
*/
|
||||
export class EventRenderer {
|
||||
private container: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
private eventService: EventService,
|
||||
private dateService: DateService,
|
||||
private gridConfig: IGridConfig,
|
||||
private eventBus: IEventBus
|
||||
) {
|
||||
this.setupDragListeners();
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup listeners for drag-drop events
|
||||
* Setup listeners for drag-drop and update events
|
||||
*/
|
||||
private setupDragListeners(): void {
|
||||
private setupListeners(): void {
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
|
||||
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
|
||||
this.handleColumnChange(payload);
|
||||
|
|
@ -37,6 +41,95 @@ export class EventRenderer {
|
|||
const payload = (e as CustomEvent<IDragMovePayload>).detail;
|
||||
this.updateDragTimestamp(payload);
|
||||
});
|
||||
|
||||
this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => {
|
||||
const payload = (e as CustomEvent<IEventUpdatedPayload>).detail;
|
||||
this.handleEventUpdated(payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle EVENT_UPDATED - re-render affected columns
|
||||
*/
|
||||
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
|
||||
// Re-render source column (if different from target)
|
||||
if (payload.sourceDateKey !== payload.targetDateKey ||
|
||||
payload.sourceResourceId !== payload.targetResourceId) {
|
||||
await this.rerenderColumn(payload.sourceDateKey, payload.sourceResourceId);
|
||||
}
|
||||
|
||||
// Re-render target column
|
||||
await this.rerenderColumn(payload.targetDateKey, payload.targetResourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render a single column with fresh data from IndexedDB
|
||||
*/
|
||||
private async rerenderColumn(dateKey: string, resourceId?: string): Promise<void> {
|
||||
const column = this.findColumn(dateKey, resourceId);
|
||||
if (!column) return;
|
||||
|
||||
// Get date range for this day
|
||||
const startDate = new Date(dateKey);
|
||||
const endDate = new Date(dateKey);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
// Fetch events from IndexedDB
|
||||
const events = resourceId
|
||||
? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate)
|
||||
: await this.eventService.getByDateRange(startDate, endDate);
|
||||
|
||||
// Filter to timed events and match dateKey exactly
|
||||
const timedEvents = events.filter(event =>
|
||||
!event.allDay && this.dateService.getDateKey(event.start) === dateKey
|
||||
);
|
||||
|
||||
// Get or create events layer
|
||||
let eventsLayer = column.querySelector('swp-events-layer');
|
||||
if (!eventsLayer) {
|
||||
eventsLayer = document.createElement('swp-events-layer');
|
||||
column.appendChild(eventsLayer);
|
||||
}
|
||||
|
||||
// Clear existing events
|
||||
eventsLayer.innerHTML = '';
|
||||
|
||||
// Calculate layout with stacking/grouping
|
||||
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
|
||||
|
||||
// Render GRID groups
|
||||
layout.grids.forEach(grid => {
|
||||
const groupEl = this.renderGridGroup(grid);
|
||||
eventsLayer!.appendChild(groupEl);
|
||||
});
|
||||
|
||||
// Render STACKED events
|
||||
layout.stacked.forEach(item => {
|
||||
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
|
||||
eventsLayer!.appendChild(eventEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a column element by dateKey and optional resourceId
|
||||
*/
|
||||
private findColumn(dateKey: string, resourceId?: string): HTMLElement | null {
|
||||
if (!this.container) return null;
|
||||
|
||||
const columns = this.container.querySelectorAll('swp-day-column');
|
||||
for (const col of columns) {
|
||||
const colEl = col as HTMLElement;
|
||||
if (colEl.dataset.date !== dateKey) continue;
|
||||
|
||||
// If resourceId specified, must match
|
||||
if (resourceId && colEl.dataset.resourceId !== resourceId) continue;
|
||||
|
||||
// If no resourceId specified but column has one, skip (simple view case)
|
||||
if (!resourceId && colEl.dataset.resourceId) continue;
|
||||
|
||||
return colEl;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,6 +186,9 @@ export class EventRenderer {
|
|||
* @param filter - Filter with 'date' and optionally 'resource' arrays
|
||||
*/
|
||||
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
|
||||
// Store container reference for later re-renders
|
||||
this.container = container;
|
||||
|
||||
const visibleDates = filter['date'] || [];
|
||||
|
||||
if (visibleDates.length === 0) return;
|
||||
|
|
@ -142,12 +238,22 @@ export class EventRenderer {
|
|||
// Clear existing events
|
||||
eventsLayer.innerHTML = '';
|
||||
|
||||
// Render each timed event
|
||||
columnEvents.forEach(event => {
|
||||
if (!event.allDay) {
|
||||
const eventElement = this.createEventElement(event);
|
||||
eventsLayer!.appendChild(eventElement);
|
||||
}
|
||||
// Filter to timed events only
|
||||
const timedEvents = columnEvents.filter(event => !event.allDay);
|
||||
|
||||
// Calculate layout with stacking/grouping
|
||||
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
|
||||
|
||||
// Render GRID groups (simultaneous events side-by-side)
|
||||
layout.grids.forEach(grid => {
|
||||
const groupEl = this.renderGridGroup(grid);
|
||||
eventsLayer!.appendChild(groupEl);
|
||||
});
|
||||
|
||||
// Render STACKED events (overlapping with margin offset)
|
||||
layout.stacked.forEach(item => {
|
||||
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
|
||||
eventsLayer!.appendChild(eventEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -162,8 +268,8 @@ export class EventRenderer {
|
|||
private createEventElement(event: ICalendarEvent): HTMLElement {
|
||||
const element = document.createElement('swp-event');
|
||||
|
||||
// Only essential data attribute
|
||||
element.dataset.id = event.id;
|
||||
// Only essential data attribute (eventId for DragDropManager compatibility)
|
||||
element.dataset.eventId = event.id;
|
||||
|
||||
// Calculate position
|
||||
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
|
||||
|
|
@ -187,9 +293,15 @@ export class EventRenderer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get color class based on event type
|
||||
* Get color class based on metadata.color or event type
|
||||
*/
|
||||
private getColorClass(event: ICalendarEvent): string {
|
||||
// Check metadata.color first
|
||||
if (event.metadata?.color) {
|
||||
return `is-${event.metadata.color}`;
|
||||
}
|
||||
|
||||
// Fallback to type-based color
|
||||
const typeColors: Record<string, string> = {
|
||||
'customer': 'is-blue',
|
||||
'vacation': 'is-green',
|
||||
|
|
@ -208,4 +320,70 @@ export class EventRenderer {
|
|||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a GRID group with side-by-side columns
|
||||
* Used when multiple events start at the same time
|
||||
*/
|
||||
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
|
||||
const group = document.createElement('swp-event-group');
|
||||
group.classList.add(`cols-${layout.columns.length}`);
|
||||
group.style.top = `${layout.position.top}px`;
|
||||
|
||||
// Stack level styling for entire group (if nested in another event)
|
||||
if (layout.stackLevel > 0) {
|
||||
group.style.marginLeft = `${layout.stackLevel * 15}px`;
|
||||
group.style.zIndex = `${100 + layout.stackLevel}`;
|
||||
}
|
||||
|
||||
// Calculate the height needed for the group (tallest event)
|
||||
let maxBottom = 0;
|
||||
for (const event of layout.events) {
|
||||
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
|
||||
const eventBottom = pos.top + pos.height;
|
||||
if (eventBottom > maxBottom) maxBottom = eventBottom;
|
||||
}
|
||||
const groupHeight = maxBottom - layout.position.top;
|
||||
group.style.height = `${groupHeight}px`;
|
||||
|
||||
// Create wrapper div for each column
|
||||
layout.columns.forEach(columnEvents => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.position = 'relative';
|
||||
|
||||
columnEvents.forEach(event => {
|
||||
const eventEl = this.createEventElement(event);
|
||||
// Position relative to group top
|
||||
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
|
||||
eventEl.style.top = `${pos.top - layout.position.top}px`;
|
||||
eventEl.style.position = 'absolute';
|
||||
eventEl.style.left = '0';
|
||||
eventEl.style.right = '0';
|
||||
wrapper.appendChild(eventEl);
|
||||
});
|
||||
|
||||
group.appendChild(wrapper);
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a STACKED event with margin-left offset
|
||||
* Used for overlapping events that don't start at the same time
|
||||
*/
|
||||
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
|
||||
const element = this.createEventElement(event);
|
||||
|
||||
// Add stack metadata for drag-drop and other features
|
||||
element.dataset.stackLink = JSON.stringify({ stackLevel });
|
||||
|
||||
// Visual styling based on stack level
|
||||
if (stackLevel > 0) {
|
||||
element.style.marginLeft = `${stackLevel * 15}px`;
|
||||
element.style.zIndex = `${100 + stackLevel}`;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export class HeaderDrawerRenderer {
|
|||
|
||||
// Create header item
|
||||
const item = document.createElement('swp-header-item');
|
||||
item.dataset.id = payload.eventId;
|
||||
item.dataset.eventId = payload.eventId;
|
||||
item.dataset.itemType = payload.itemType;
|
||||
item.dataset.date = payload.sourceDate;
|
||||
item.dataset.duration = String(payload.duration);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ interface DragState {
|
|||
targetY: number;
|
||||
currentY: number;
|
||||
animationId: number;
|
||||
sourceDateKey: string; // Source column date (where drag started)
|
||||
sourceResourceId?: string; // Source column resource (where drag started)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,7 +146,7 @@ export class DragDropManager {
|
|||
// Remove ghost
|
||||
this.dragState.ghostElement.remove();
|
||||
|
||||
// Get column data
|
||||
// Get column data (target = current column, source = where drag started)
|
||||
const dateKey = this.dragState.columnElement.dataset.date || '';
|
||||
const resourceId = this.dragState.columnElement.dataset.resourceId;
|
||||
|
||||
|
|
@ -155,7 +157,9 @@ export class DragDropManager {
|
|||
snappedY,
|
||||
columnElement: this.dragState.columnElement,
|
||||
dateKey,
|
||||
resourceId
|
||||
resourceId,
|
||||
sourceDateKey: this.dragState.sourceDateKey,
|
||||
sourceResourceId: this.dragState.sourceResourceId
|
||||
};
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
|
||||
|
|
@ -200,7 +204,9 @@ export class DragDropManager {
|
|||
currentColumn: columnElement,
|
||||
targetY: Math.max(0, targetY),
|
||||
currentY: startY,
|
||||
animationId: 0
|
||||
animationId: 0,
|
||||
sourceDateKey: columnElement.dataset.date || '',
|
||||
sourceResourceId: columnElement.dataset.resourceId
|
||||
};
|
||||
|
||||
// Emit drag:start
|
||||
|
|
|
|||
116
src/v2/managers/EventPersistenceManager.ts
Normal file
116
src/v2/managers/EventPersistenceManager.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* EventPersistenceManager - Persists event changes to IndexedDB
|
||||
*
|
||||
* Listens to drag/resize events and updates IndexedDB via EventService.
|
||||
* This bridges the gap between UI interactions and data persistence.
|
||||
*/
|
||||
|
||||
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../types/CalendarTypes';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { IGridConfig } from '../core/IGridConfig';
|
||||
import { DateService } from '../core/DateService';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { IDragEndPayload } from '../types/DragTypes';
|
||||
import { IResizeEndPayload } from '../types/ResizeTypes';
|
||||
import { pixelsToMinutes } from '../utils/PositionUtils';
|
||||
|
||||
export class EventPersistenceManager {
|
||||
constructor(
|
||||
private eventService: EventService,
|
||||
private eventBus: IEventBus,
|
||||
private gridConfig: IGridConfig,
|
||||
private dateService: DateService
|
||||
) {
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_END, this.handleDragEnd);
|
||||
this.eventBus.on(CoreEvents.EVENT_RESIZE_END, this.handleResizeEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag end - update event position in IndexedDB
|
||||
*/
|
||||
private handleDragEnd = async (e: Event): Promise<void> => {
|
||||
const payload = (e as CustomEvent<IDragEndPayload>).detail;
|
||||
|
||||
// Get existing event
|
||||
const event = await this.eventService.get(payload.eventId);
|
||||
if (!event) {
|
||||
console.warn(`EventPersistenceManager: Event ${payload.eventId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new start time from snappedY
|
||||
const minutesFromDayStart = pixelsToMinutes(payload.snappedY, this.gridConfig);
|
||||
const totalMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromDayStart;
|
||||
|
||||
// Preserve duration
|
||||
const durationMs = event.end.getTime() - event.start.getTime();
|
||||
|
||||
// Create new dates with correct day from dateKey
|
||||
const newStart = new Date(payload.dateKey);
|
||||
newStart.setHours(Math.floor(totalMinutes / 60), totalMinutes % 60, 0, 0);
|
||||
const newEnd = new Date(newStart.getTime() + durationMs);
|
||||
|
||||
// Update and save
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...event,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
resourceId: payload.resourceId ?? event.resourceId,
|
||||
syncStatus: 'pending'
|
||||
};
|
||||
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns
|
||||
const updatePayload: IEventUpdatedPayload = {
|
||||
eventId: updatedEvent.id,
|
||||
sourceDateKey: payload.sourceDateKey,
|
||||
sourceResourceId: payload.sourceResourceId,
|
||||
targetDateKey: payload.dateKey,
|
||||
targetResourceId: payload.resourceId
|
||||
};
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle resize end - update event duration in IndexedDB
|
||||
*/
|
||||
private handleResizeEnd = async (e: Event): Promise<void> => {
|
||||
const payload = (e as CustomEvent<IResizeEndPayload>).detail;
|
||||
|
||||
// Get existing event
|
||||
const event = await this.eventService.get(payload.eventId);
|
||||
if (!event) {
|
||||
console.warn(`EventPersistenceManager: Event ${payload.eventId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new end time
|
||||
const newEnd = new Date(event.start.getTime() + payload.newDurationMinutes * 60 * 1000);
|
||||
|
||||
// Update and save
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...event,
|
||||
end: newEnd,
|
||||
syncStatus: 'pending'
|
||||
};
|
||||
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
// Emit EVENT_UPDATED for EventRenderer to re-render the column
|
||||
// Resize stays in same column, so source and target are the same
|
||||
const dateKey = this.dateService.getDateKey(event.start);
|
||||
const updatePayload: IEventUpdatedPayload = {
|
||||
eventId: updatedEvent.id,
|
||||
sourceDateKey: dateKey,
|
||||
sourceResourceId: event.resourceId,
|
||||
targetDateKey: dateKey,
|
||||
targetResourceId: event.resourceId
|
||||
};
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
|
||||
};
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ export class ResizeManager {
|
|||
const element = handle.parentElement as HTMLElement;
|
||||
if (!element) return;
|
||||
|
||||
const eventId = element.dataset.id || '';
|
||||
const eventId = element.dataset.eventId || '';
|
||||
const startHeight = element.offsetHeight;
|
||||
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
|
||||
|
||||
|
|
|
|||
49
src/v2/repositories/MockAuditRepository.ts
Normal file
49
src/v2/repositories/MockAuditRepository.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { IApiRepository } from './IApiRepository';
|
||||
import { IAuditEntry } from '../types/AuditTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* MockAuditRepository - Mock API repository for audit entries
|
||||
*
|
||||
* In production, this would send audit entries to the backend.
|
||||
* For development/testing, it just logs the operations.
|
||||
*/
|
||||
export class MockAuditRepository implements IApiRepository<IAuditEntry> {
|
||||
readonly entityType: EntityType = 'Audit';
|
||||
|
||||
async sendCreate(entity: IAuditEntry): Promise<IAuditEntry> {
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log('MockAuditRepository: Audit entry synced to backend:', {
|
||||
id: entity.id,
|
||||
entityType: entity.entityType,
|
||||
entityId: entity.entityId,
|
||||
operation: entity.operation,
|
||||
timestamp: new Date(entity.timestamp).toISOString()
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async sendUpdate(_id: string, _entity: IAuditEntry): Promise<IAuditEntry> {
|
||||
// Audit entries are immutable - updates should not happen
|
||||
throw new Error('Audit entries cannot be updated');
|
||||
}
|
||||
|
||||
async sendDelete(_id: string): Promise<void> {
|
||||
// Audit entries should never be deleted
|
||||
throw new Error('Audit entries cannot be deleted');
|
||||
}
|
||||
|
||||
async fetchAll(): Promise<IAuditEntry[]> {
|
||||
// For now, return empty array - audit entries are local-first
|
||||
// In production, this could fetch audit history from backend
|
||||
return [];
|
||||
}
|
||||
|
||||
async fetchById(_id: string): Promise<IAuditEntry | null> {
|
||||
// For now, return null - audit entries are local-first
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
167
src/v2/storage/audit/AuditService.ts
Normal file
167
src/v2/storage/audit/AuditService.ts
Normal 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}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/v2/storage/audit/AuditStore.ts
Normal file
27
src/v2/storage/audit/AuditStore.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
src/v2/types/AuditTypes.ts
Normal file
46
src/v2/types/AuditTypes.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ISync, EntityType } from './CalendarTypes';
|
||||
|
||||
/**
|
||||
* IAuditEntry - Audit log entry for tracking all entity changes
|
||||
*
|
||||
* Used for:
|
||||
* - Compliance and audit trail
|
||||
* - Sync tracking with backend
|
||||
* - Change history
|
||||
*/
|
||||
export interface IAuditEntry extends ISync {
|
||||
/** Unique audit entry ID */
|
||||
id: string;
|
||||
|
||||
/** Type of entity that was changed */
|
||||
entityType: EntityType;
|
||||
|
||||
/** ID of the entity that was changed */
|
||||
entityId: string;
|
||||
|
||||
/** Type of operation performed */
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
|
||||
/** User who made the change */
|
||||
userId: string;
|
||||
|
||||
/** Timestamp when change was made */
|
||||
timestamp: number;
|
||||
|
||||
/** Changes made (full entity for create, diff for update, { id } for delete) */
|
||||
changes: unknown;
|
||||
|
||||
/** Whether this audit entry has been synced to backend */
|
||||
synced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* IAuditLoggedPayload - Event payload when audit entry is logged
|
||||
*/
|
||||
export interface IAuditLoggedPayload {
|
||||
auditId: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
timestamp: number;
|
||||
}
|
||||
|
|
@ -78,13 +78,26 @@ export interface IEventBus {
|
|||
// Entity event payloads
|
||||
export interface IEntitySavedPayload {
|
||||
entityType: EntityType;
|
||||
entity: ISync;
|
||||
isNew: boolean;
|
||||
entityId: string;
|
||||
operation: 'create' | 'update';
|
||||
changes: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IEntityDeletedPayload {
|
||||
entityType: EntityType;
|
||||
id: string;
|
||||
entityId: string;
|
||||
operation: 'delete';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Event update payload (for re-rendering columns after drag/resize)
|
||||
export interface IEventUpdatedPayload {
|
||||
eventId: string;
|
||||
sourceDateKey: string; // Source column date (where event came from)
|
||||
sourceResourceId?: string; // Source column resource
|
||||
targetDateKey: string; // Target column date (where event landed)
|
||||
targetResourceId?: string; // Target column resource
|
||||
}
|
||||
|
||||
// Resource types
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ export interface IDragEndPayload {
|
|||
element: HTMLElement;
|
||||
snappedY: number; // Final snapped position
|
||||
columnElement: HTMLElement;
|
||||
dateKey: string; // From column dataset
|
||||
resourceId?: string; // From column dataset (resource mode)
|
||||
dateKey: string; // Target column date (from dataset)
|
||||
resourceId?: string; // Target column resource (resource mode)
|
||||
sourceDateKey: string; // Source column date (where drag started)
|
||||
sourceResourceId?: string; // Source column resource (where drag started)
|
||||
}
|
||||
|
||||
export interface IDragCancelPayload {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export class DataSeeder {
|
|||
console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`);
|
||||
|
||||
for (const entity of data) {
|
||||
await service.save(entity);
|
||||
await service.save(entity, true); // silent = true to skip audit logging
|
||||
}
|
||||
|
||||
console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue