From 70172e8f10c44560ddd79a92dd2f708594f79649 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Thu, 11 Dec 2025 18:11:11 +0100 Subject: [PATCH] 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 --- .claude/settings.local.json | 3 +- .workbench/scenarios/v2-scenario-renderer.js | 323 ++++++++++++++++++ .workbench/scenarios/v2-scenarios.html | 307 +++++++++++++++++ src/v2/V2CompositionRoot.ts | 18 +- src/v2/constants/CoreEvents.ts | 3 + src/v2/core/IGridConfig.ts | 1 + src/v2/demo/DemoApp.ts | 6 +- src/v2/features/event/EventLayoutEngine.ts | 279 +++++++++++++++ src/v2/features/event/EventLayoutTypes.ts | 35 ++ src/v2/features/event/EventRenderer.ts | 204 ++++++++++- .../headerdrawer/HeaderDrawerRenderer.ts | 2 +- src/v2/managers/DragDropManager.ts | 12 +- src/v2/managers/EventPersistenceManager.ts | 116 +++++++ src/v2/managers/ResizeManager.ts | 2 +- src/v2/repositories/MockAuditRepository.ts | 49 +++ src/v2/storage/BaseEntityService.ts | 41 ++- src/v2/storage/IEntityService.ts | 4 +- src/v2/storage/audit/AuditService.ts | 167 +++++++++ src/v2/storage/audit/AuditStore.ts | 27 ++ src/v2/types/AuditTypes.ts | 46 +++ src/v2/types/CalendarTypes.ts | 19 +- src/v2/types/DragTypes.ts | 6 +- src/v2/workers/DataSeeder.ts | 2 +- test/v2/EventLayoutEngine.test.ts | 258 ++++++++++++++ vitest.config.ts | 2 + wwwroot/data/mock-events.json | 220 +++++++++++- 26 files changed, 2108 insertions(+), 44 deletions(-) create mode 100644 .workbench/scenarios/v2-scenario-renderer.js create mode 100644 .workbench/scenarios/v2-scenarios.html create mode 100644 src/v2/features/event/EventLayoutEngine.ts create mode 100644 src/v2/features/event/EventLayoutTypes.ts create mode 100644 src/v2/managers/EventPersistenceManager.ts create mode 100644 src/v2/repositories/MockAuditRepository.ts create mode 100644 src/v2/storage/audit/AuditService.ts create mode 100644 src/v2/storage/audit/AuditStore.ts create mode 100644 src/v2/types/AuditTypes.ts create mode 100644 test/v2/EventLayoutEngine.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d91595a..3a069d9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "WebFetch(domain:github.com)", "Bash(npm install:*)", "WebFetch(domain:raw.githubusercontent.com)", - "Bash(npm run css:analyze:*)" + "Bash(npm run css:analyze:*)", + "Bash(npm run test:run:*)" ], "deny": [], "ask": [] diff --git a/.workbench/scenarios/v2-scenario-renderer.js b/.workbench/scenarios/v2-scenario-renderer.js new file mode 100644 index 0000000..e9006aa --- /dev/null +++ b/.workbench/scenarios/v2-scenario-renderer.js @@ -0,0 +1,323 @@ +/** + * V2 Scenario Renderer + * Uses EventLayoutEngine from V2 to render events dynamically + */ + +// Import the compiled V2 layout engine +// We'll inline the algorithm here for standalone use in browser + +// ============================================ +// EventLayoutEngine (copied from V2 for browser use) +// ============================================ + +function eventsOverlap(a, b) { + return a.start < b.end && a.end > b.start; +} + +function eventsWithinThreshold(a, b, thresholdMinutes) { + const thresholdMs = thresholdMinutes * 60 * 1000; + + const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime()); + if (startToStartDiff <= thresholdMs) return true; + + const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime(); + if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true; + + const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime(); + if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true; + + return false; +} + +function findOverlapGroups(events) { + if (events.length === 0) return []; + + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + const used = new Set(); + const groups = []; + + for (const event of sorted) { + if (used.has(event.id)) continue; + + const group = [event]; + used.add(event.id); + + let expanded = true; + while (expanded) { + expanded = false; + for (const candidate of sorted) { + if (used.has(candidate.id)) continue; + + const connects = group.some(member => eventsOverlap(member, candidate)); + + if (connects) { + group.push(candidate); + used.add(candidate.id); + expanded = true; + } + } + } + + groups.push(group); + } + + return groups; +} + +function findGridCandidates(events, thresholdMinutes) { + if (events.length === 0) return []; + + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + const used = new Set(); + const groups = []; + + for (const event of sorted) { + if (used.has(event.id)) continue; + + const group = [event]; + used.add(event.id); + + 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; +} + +function calculateStackLevels(events) { + const levels = new Map(); + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + + for (const event of sorted) { + let maxOverlappingLevel = -1; + + 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; +} + +function allocateColumns(events) { + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + const columns = []; + + for (const event of sorted) { + let placed = false; + for (const column of columns) { + const canFit = !column.some(e => eventsOverlap(event, e)); + if (canFit) { + column.push(event); + placed = true; + break; + } + } + + if (!placed) { + columns.push([event]); + } + } + + return columns; +} + +function calculateEventPosition(start, end, config) { + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + + const dayStartMinutes = config.dayStartHour * 60; + const minuteHeight = config.hourHeight / 60; + + const top = (startMinutes - dayStartMinutes) * minuteHeight; + const height = (endMinutes - startMinutes) * minuteHeight; + + return { top, height }; +} + +function calculateColumnLayout(events, config) { + const thresholdMinutes = config.gridStartThresholdMinutes ?? 30; + + const result = { + grids: [], + stacked: [] + }; + + if (events.length === 0) return result; + + const overlapGroups = findOverlapGroups(events); + + for (const overlapGroup of overlapGroups) { + if (overlapGroup.length === 1) { + result.stacked.push({ + event: overlapGroup[0], + stackLevel: 0 + }); + continue; + } + + const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes); + + const largestGridCandidate = gridSubgroups.reduce((max, g) => + g.length > max.length ? g : max, gridSubgroups[0]); + + if (largestGridCandidate.length === overlapGroup.length) { + 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 { + const levels = calculateStackLevels(overlapGroup); + for (const event of overlapGroup) { + result.stacked.push({ + event, + stackLevel: levels.get(event.id) ?? 0 + }); + } + } + } + + return result; +} + +// ============================================ +// Scenario Renderer +// ============================================ + +const gridConfig = { + hourHeight: 80, + dayStartHour: 8, + dayEndHour: 20, + snapInterval: 15, + gridStartThresholdMinutes: 30 +}; + +function formatTime(date) { + return date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' }); +} + +function createEventElement(event, config) { + const element = document.createElement('swp-event'); + element.dataset.eventId = event.id; + + const position = calculateEventPosition(event.start, event.end, config); + element.style.position = 'absolute'; + element.style.top = `${position.top}px`; + element.style.height = `${position.height}px`; + element.style.left = '2px'; + element.style.right = '2px'; + + element.innerHTML = ` + ${formatTime(event.start)} - ${formatTime(event.end)} + ${event.title} + `; + + return element; +} + +function renderGridGroup(layout, config) { + const group = document.createElement('swp-event-group'); + group.classList.add(`cols-${layout.columns.length}`); + group.style.top = `${layout.position.top}px`; + + // Stack level styling + group.dataset.stackLink = JSON.stringify({ stackLevel: layout.stackLevel }); + if (layout.stackLevel > 0) { + group.style.marginLeft = `${layout.stackLevel * 15}px`; + group.style.zIndex = `${100 + layout.stackLevel}`; + } + + // Calculate height + let maxBottom = 0; + for (const event of layout.events) { + const pos = calculateEventPosition(event.start, event.end, config); + const eventBottom = pos.top + pos.height; + if (eventBottom > maxBottom) maxBottom = eventBottom; + } + group.style.height = `${maxBottom - layout.position.top}px`; + + // Create columns + layout.columns.forEach(columnEvents => { + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + + columnEvents.forEach(event => { + const eventEl = createEventElement(event, config); + const pos = calculateEventPosition(event.start, event.end, config); + eventEl.style.top = `${pos.top - layout.position.top}px`; + eventEl.style.left = '0'; + eventEl.style.right = '0'; + wrapper.appendChild(eventEl); + }); + + group.appendChild(wrapper); + }); + + return group; +} + +function renderStackedEvent(event, stackLevel, config) { + const element = createEventElement(event, config); + + element.dataset.stackLink = JSON.stringify({ stackLevel }); + + if (stackLevel > 0) { + element.style.marginLeft = `${stackLevel * 15}px`; + element.style.zIndex = `${100 + stackLevel}`; + } else { + element.style.zIndex = '100'; + } + + return element; +} + +export function renderScenario(container, events, config = gridConfig) { + container.innerHTML = ''; + + const layout = calculateColumnLayout(events, config); + + // Render grids + layout.grids.forEach(grid => { + const groupEl = renderGridGroup(grid, config); + container.appendChild(groupEl); + }); + + // Render stacked events + layout.stacked.forEach(item => { + const eventEl = renderStackedEvent(item.event, item.stackLevel, config); + container.appendChild(eventEl); + }); + + return layout; +} + +export { calculateColumnLayout, gridConfig }; diff --git a/.workbench/scenarios/v2-scenarios.html b/.workbench/scenarios/v2-scenarios.html new file mode 100644 index 0000000..b39fe94 --- /dev/null +++ b/.workbench/scenarios/v2-scenarios.html @@ -0,0 +1,307 @@ + + + + + + V2 Event Layout Engine - All Scenarios + + + + +
+

V2 Event Layout Engine - Visual Tests

+ +
+

Test Summary

+

+ 0 passed, + 0 failed +

+

Using V2 EventLayoutEngine with gridStartThresholdMinutes: 30

+
+ +
+ +
+
+ + + + diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index 3056a7d..031b150 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -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(); builder.registerType(CustomerStore).as(); builder.registerType(ScheduleOverrideStore).as(); + builder.registerType(AuditStore).as(); // Entity services (for DataSeeder polymorphic array) builder.registerType(EventService).as>(); @@ -126,6 +135,12 @@ export function createV2Container(): Container { builder.registerType(MockCustomerRepository).as>(); builder.registerType(MockCustomerRepository).as>(); + builder.registerType(MockAuditRepository).as>(); + builder.registerType(MockAuditRepository).as>(); + + // Audit service (listens to ENTITY_SAVED/DELETED events automatically) + builder.registerType(AuditService).as(); + // Workers builder.registerType(DataSeeder).as(); @@ -155,6 +170,7 @@ export function createV2Container(): Container { builder.registerType(DragDropManager).as(); builder.registerType(EdgeScrollManager).as(); builder.registerType(ResizeManager).as(); + builder.registerType(EventPersistenceManager).as(); // Demo app builder.registerType(DemoApp).as(); diff --git a/src/v2/constants/CoreEvents.ts b/src/v2/constants/CoreEvents.ts index 917bdc0..9c25100 100644 --- a/src/v2/constants/CoreEvents.ts +++ b/src/v2/constants/CoreEvents.ts @@ -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; diff --git a/src/v2/core/IGridConfig.ts b/src/v2/core/IGridConfig.ts index 3703968..03c6a2f 100644 --- a/src/v2/core/IGridConfig.ts +++ b/src/v2/core/IGridConfig.ts @@ -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) } diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index c970835..b5a1464 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -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 { diff --git a/src/v2/features/event/EventLayoutEngine.ts b/src/v2/features/event/EventLayoutEngine.ts new file mode 100644 index 0000000..4606933 --- /dev/null +++ b/src/v2/features/event/EventLayoutEngine.ts @@ -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(); + 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(); + 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 { + const levels = new Map(); + 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; +} diff --git a/src/v2/features/event/EventLayoutTypes.ts b/src/v2/features/event/EventLayoutTypes.ts new file mode 100644 index 0000000..c887eaf --- /dev/null +++ b/src/v2/features/event/EventLayoutTypes.ts @@ -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[]; +} diff --git a/src/v2/features/event/EventRenderer.ts b/src/v2/features/event/EventRenderer.ts index d8934ea..b53a190 100644 --- a/src/v2/features/event/EventRenderer.ts +++ b/src/v2/features/event/EventRenderer.ts @@ -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).detail; this.handleColumnChange(payload); @@ -37,6 +41,95 @@ export class EventRenderer { const payload = (e as CustomEvent).detail; this.updateDragTimestamp(payload); }); + + this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => { + const payload = (e as CustomEvent).detail; + this.handleEventUpdated(payload); + }); + } + + /** + * Handle EVENT_UPDATED - re-render affected columns + */ + private async handleEventUpdated(payload: IEventUpdatedPayload): Promise { + // 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 { + 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): Promise { + // 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 = { '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; + } } diff --git a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts index e1ef734..b0738f2 100644 --- a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts +++ b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts @@ -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); diff --git a/src/v2/managers/DragDropManager.ts b/src/v2/managers/DragDropManager.ts index 3dab5f4..e6c5ab2 100644 --- a/src/v2/managers/DragDropManager.ts +++ b/src/v2/managers/DragDropManager.ts @@ -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 diff --git a/src/v2/managers/EventPersistenceManager.ts b/src/v2/managers/EventPersistenceManager.ts new file mode 100644 index 0000000..5241779 --- /dev/null +++ b/src/v2/managers/EventPersistenceManager.ts @@ -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 => { + const payload = (e as CustomEvent).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 => { + const payload = (e as CustomEvent).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); + }; +} diff --git a/src/v2/managers/ResizeManager.ts b/src/v2/managers/ResizeManager.ts index d5892d4..e557bfe 100644 --- a/src/v2/managers/ResizeManager.ts +++ b/src/v2/managers/ResizeManager.ts @@ -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); diff --git a/src/v2/repositories/MockAuditRepository.ts b/src/v2/repositories/MockAuditRepository.ts new file mode 100644 index 0000000..211fc4f --- /dev/null +++ b/src/v2/repositories/MockAuditRepository.ts @@ -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 { + readonly entityType: EntityType = 'Audit'; + + async sendCreate(entity: IAuditEntry): Promise { + // 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 { + // Audit entries are immutable - updates should not happen + throw new Error('Audit entries cannot be updated'); + } + + async sendDelete(_id: string): Promise { + // Audit entries should never be deleted + throw new Error('Audit entries cannot be deleted'); + } + + async fetchAll(): Promise { + // 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 { + // For now, return null - audit entries are local-first + return null; + } +} diff --git a/src/v2/storage/BaseEntityService.ts b/src/v2/storage/BaseEntityService.ts index cd4cbeb..ed8d3a1 100644 --- a/src/v2/storage/BaseEntityService.ts +++ b/src/v2/storage/BaseEntityService.ts @@ -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 - Abstract base class for all entity services @@ -87,11 +88,25 @@ export abstract class BaseEntityService 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 { + async save(entity: T, silent = false): Promise { 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 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 implements IEntityServi /** * Delete an entity + * Emits ENTITY_DELETED event */ async delete(id: string): Promise { return new Promise((resolve, reject) => { @@ -127,7 +148,9 @@ export abstract class BaseEntityService 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(); diff --git a/src/v2/storage/IEntityService.ts b/src/v2/storage/IEntityService.ts index be18ce4..800ea62 100644 --- a/src/v2/storage/IEntityService.ts +++ b/src/v2/storage/IEntityService.ts @@ -18,8 +18,10 @@ export interface IEntityService { /** * 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; + save(entity: T, silent?: boolean): Promise; /** * Mark entity as successfully synced diff --git a/src/v2/storage/audit/AuditService.ts b/src/v2/storage/audit/AuditService.ts new file mode 100644 index 0000000..bf69664 --- /dev/null +++ b/src/v2/storage/audit/AuditService.ts @@ -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 { + 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 { + // 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 { + // 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 { + 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 { + throw new Error('Audit entries cannot be deleted (compliance requirement)'); + } + + /** + * Get pending audit entries (for sync) + */ + async getPendingAudits(): Promise { + return this.getBySyncStatus('pending'); + } + + /** + * Get audit entries for a specific entity + */ + async getByEntityId(entityId: string): Promise { + 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}`)); + }; + }); + } +} diff --git a/src/v2/storage/audit/AuditStore.ts b/src/v2/storage/audit/AuditStore.ts new file mode 100644 index 0000000..00caf8b --- /dev/null +++ b/src/v2/storage/audit/AuditStore.ts @@ -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 }); + } +} diff --git a/src/v2/types/AuditTypes.ts b/src/v2/types/AuditTypes.ts new file mode 100644 index 0000000..3c0eb9f --- /dev/null +++ b/src/v2/types/AuditTypes.ts @@ -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; +} diff --git a/src/v2/types/CalendarTypes.ts b/src/v2/types/CalendarTypes.ts index af9ebc7..79c32ba 100644 --- a/src/v2/types/CalendarTypes.ts +++ b/src/v2/types/CalendarTypes.ts @@ -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 diff --git a/src/v2/types/DragTypes.ts b/src/v2/types/DragTypes.ts index f7a25f6..45e157c 100644 --- a/src/v2/types/DragTypes.ts +++ b/src/v2/types/DragTypes.ts @@ -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 { diff --git a/src/v2/workers/DataSeeder.ts b/src/v2/workers/DataSeeder.ts index 62aada8..b05bf40 100644 --- a/src/v2/workers/DataSeeder.ts +++ b/src/v2/workers/DataSeeder.ts @@ -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)`); diff --git a/test/v2/EventLayoutEngine.test.ts b/test/v2/EventLayoutEngine.test.ts new file mode 100644 index 0000000..9064fdf --- /dev/null +++ b/test/v2/EventLayoutEngine.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from 'vitest'; +import { calculateColumnLayout, eventsOverlap } from '../../src/v2/features/event/EventLayoutEngine'; +import { ICalendarEvent } from '../../src/v2/types/CalendarTypes'; +import { IGridConfig } from '../../src/v2/core/IGridConfig'; + +// Helper to create test events +function createEvent(id: string, startHour: number, startMin: number, endHour: number, endMin: number): ICalendarEvent { + const baseDate = new Date('2025-10-06'); + const start = new Date(baseDate); + start.setHours(startHour, startMin, 0, 0); + const end = new Date(baseDate); + end.setHours(endHour, endMin, 0, 0); + + return { + id, + title: `Event ${id}`, + start, + end, + type: 'work', + allDay: false + }; +} + +const gridConfig: IGridConfig = { + hourHeight: 60, + dayStartHour: 8, + dayEndHour: 20, + snapInterval: 15, + gridStartThresholdMinutes: 30 // Match calendar-config.json +}; + +describe('EventLayoutEngine', () => { + + describe('eventsOverlap', () => { + it('should return true for overlapping events', () => { + const a = createEvent('a', 10, 0, 11, 0); + const b = createEvent('b', 10, 30, 11, 30); + expect(eventsOverlap(a, b)).toBe(true); + }); + + it('should return false for edge-adjacent events (end === start)', () => { + const a = createEvent('a', 10, 0, 11, 0); + const b = createEvent('b', 11, 0, 12, 0); + expect(eventsOverlap(a, b)).toBe(false); + }); + + it('should return false for non-overlapping events', () => { + const a = createEvent('a', 10, 0, 11, 0); + const b = createEvent('b', 12, 0, 13, 0); + expect(eventsOverlap(a, b)).toBe(false); + }); + }); + + describe('Scenario 1: No Overlap', () => { + it('should assign stackLevel=0 to all sequential non-overlapping events', () => { + const events = [ + createEvent('S1A', 10, 0, 11, 0), + createEvent('S1B', 11, 0, 12, 0), + createEvent('S1C', 12, 0, 13, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.grids).toHaveLength(0); + expect(layout.stacked).toHaveLength(3); + + const levels = layout.stacked.map(s => ({ id: s.event.id, level: s.stackLevel })); + expect(levels).toContainEqual({ id: 'S1A', level: 0 }); + expect(levels).toContainEqual({ id: 'S1B', level: 0 }); + expect(levels).toContainEqual({ id: 'S1C', level: 0 }); + }); + }); + + describe('Scenario 2: Column Sharing (Grid)', () => { + it('should create grid with 2 columns for simultaneous events', () => { + const events = [ + createEvent('S2A', 10, 0, 11, 0), + createEvent('S2B', 10, 0, 11, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.stacked).toHaveLength(0); + expect(layout.grids).toHaveLength(1); + + const grid = layout.grids[0]; + expect(grid.columns).toHaveLength(2); + expect(grid.stackLevel).toBe(0); + }); + }); + + describe('Scenario 3: Nested Stacking', () => { + it('should calculate progressive stack levels for nested events', () => { + // A: 09:00-15:00, B: 10:00-13:00, C: 11:00-12:00, D: 12:30-13:30 + const events = [ + createEvent('S3A', 9, 0, 15, 0), + createEvent('S3B', 10, 0, 13, 0), + createEvent('S3C', 11, 0, 12, 0), + createEvent('S3D', 12, 30, 13, 30) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.grids).toHaveLength(0); + expect(layout.stacked).toHaveLength(4); + + const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel; + expect(getLevel('S3A')).toBe(0); + expect(getLevel('S3B')).toBe(1); + expect(getLevel('S3C')).toBe(2); + expect(getLevel('S3D')).toBe(2); + }); + }); + + describe('Scenario 4: Complex Stacking', () => { + it('should handle long event with multiple nested events at different times', () => { + // A: 14:00-20:00, B: 15:00-17:00, C: 15:30-16:30, D: 18:00-19:00 + const events = [ + createEvent('S4A', 14, 0, 20, 0), + createEvent('S4B', 15, 0, 17, 0), + createEvent('S4C', 15, 30, 16, 30), + createEvent('S4D', 18, 0, 19, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.grids).toHaveLength(0); + expect(layout.stacked).toHaveLength(4); + + const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel; + expect(getLevel('S4A')).toBe(0); + expect(getLevel('S4B')).toBe(1); + expect(getLevel('S4C')).toBe(2); + expect(getLevel('S4D')).toBe(1); + }); + }); + + describe('Scenario 5: Three Column Share', () => { + it('should create grid with 3 columns for 3 simultaneous events', () => { + const events = [ + createEvent('S5A', 10, 0, 11, 0), + createEvent('S5B', 10, 0, 11, 0), + createEvent('S5C', 10, 0, 11, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.stacked).toHaveLength(0); + expect(layout.grids).toHaveLength(1); + + const grid = layout.grids[0]; + expect(grid.columns).toHaveLength(3); + expect(grid.stackLevel).toBe(0); + }); + }); + + describe('Scenario 6: Overlapping Pairs', () => { + it('should handle two independent pairs of overlapping events', () => { + // Pair 1: A (10:00-12:00), B (11:00-12:00) + // Pair 2: C (13:00-15:00), D (14:00-15:00) + const events = [ + createEvent('S6A', 10, 0, 12, 0), + createEvent('S6B', 11, 0, 12, 0), + createEvent('S6C', 13, 0, 15, 0), + createEvent('S6D', 14, 0, 15, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.grids).toHaveLength(0); + expect(layout.stacked).toHaveLength(4); + + const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel; + expect(getLevel('S6A')).toBe(0); + expect(getLevel('S6B')).toBe(1); + expect(getLevel('S6C')).toBe(0); + expect(getLevel('S6D')).toBe(1); + }); + }); + + describe('Scenario 7: Long Event Container', () => { + it('should assign same level to non-overlapping events inside container', () => { + // A: 09:00-15:00 (container), B: 10:00-11:00, C: 12:00-13:00 + const events = [ + createEvent('S7A', 9, 0, 15, 0), + createEvent('S7B', 10, 0, 11, 0), + createEvent('S7C', 12, 0, 13, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.grids).toHaveLength(0); + expect(layout.stacked).toHaveLength(3); + + const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel; + expect(getLevel('S7A')).toBe(0); + expect(getLevel('S7B')).toBe(1); + expect(getLevel('S7C')).toBe(1); + }); + }); + + describe('Scenario 8: Edge-Adjacent Events', () => { + it('should not stack events that touch at boundary (end === start)', () => { + const events = [ + createEvent('S8A', 10, 0, 11, 0), + createEvent('S8B', 11, 0, 12, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.grids).toHaveLength(0); + expect(layout.stacked).toHaveLength(2); + + const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel; + expect(getLevel('S8A')).toBe(0); + expect(getLevel('S8B')).toBe(0); + }); + }); + + describe('Scenario 9: End-to-Start Chain', () => { + it('should create grid for events connected through conflict chain', () => { + // A: 12:00-13:00, B: 12:30-13:30, C: 13:15-15:00 + // A overlaps B, B overlaps C, but they're within threshold -> GRID + const events = [ + createEvent('S9A', 12, 0, 13, 0), + createEvent('S9B', 12, 30, 13, 30), + createEvent('S9C', 13, 15, 15, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + // This should create a grid because events are within threshold + expect(layout.grids).toHaveLength(1); + expect(layout.grids[0].columns).toHaveLength(2); + expect(layout.grids[0].events).toHaveLength(3); + }); + }); + + describe('Scenario 10: Four Column Grid', () => { + it('should create grid with 4 columns for 4 simultaneous events', () => { + const events = [ + createEvent('S10A', 14, 0, 15, 0), + createEvent('S10B', 14, 0, 15, 0), + createEvent('S10C', 14, 0, 15, 0), + createEvent('S10D', 14, 0, 15, 0) + ]; + + const layout = calculateColumnLayout(events, gridConfig); + + expect(layout.stacked).toHaveLength(0); + expect(layout.grids).toHaveLength(1); + + const grid = layout.grids[0]; + expect(grid.columns).toHaveLength(4); + expect(grid.stackLevel).toBe(0); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a372cd6..2a97a5f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./test/setup.ts'], globals: true, + include: ['test/v2/**/*.test.ts'], + exclude: ['test/managers/**', 'test/utils/**'], }, }); \ No newline at end of file diff --git a/wwwroot/data/mock-events.json b/wwwroot/data/mock-events.json index 66d800e..8e531c3 100644 --- a/wwwroot/data/mock-events.json +++ b/wwwroot/data/mock-events.json @@ -1,16 +1,222 @@ [ { - "id": "RES-DEC08-001", - "title": "Balayage", - "description": "Test event for V2 rendering", - "start": "2025-12-08T09:00:00Z", - "end": "2025-12-08T11:00:00Z", + "id": "EVT-DEC08-001", + "title": "Balayage langt hår", + "description": "Fuld balayage behandling", + "start": "2025-12-08T10:00:00", + "end": "2025-12-08T11:00:00", "type": "customer", "allDay": false, - "bookingId": "BOOK-DEC08-001", + "bookingId": "BOOK-001", "resourceId": "EMP001", "customerId": "CUST001", "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } + "metadata": { "duration": 60, "color": "purple" } + }, + { + "id": "EVT-DEC08-002", + "title": "Klipning og styling", + "description": "Dameklipning med føn", + "start": "2025-12-08T14:00:00", + "end": "2025-12-08T15:30:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-002", + "resourceId": "EMP001", + "customerId": "CUST002", + "syncStatus": "synced", + "metadata": { "duration": 90, "color": "pink" } + }, + { + "id": "EVT-DEC08-003", + "title": "Permanent", + "description": "Permanent med curler", + "start": "2025-12-08T09:00:00", + "end": "2025-12-08T11:00:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-003", + "resourceId": "EMP002", + "customerId": "CUST003", + "syncStatus": "synced", + "metadata": { "duration": 120, "color": "indigo" } + }, + { + "id": "EVT-DEC08-004", + "title": "Farve behandling", + "description": "Farve og pleje", + "start": "2025-12-08T13:00:00", + "end": "2025-12-08T15:00:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-004", + "resourceId": "EMP002", + "customerId": "CUST004", + "syncStatus": "synced", + "metadata": { "duration": 120, "color": "orange" } + }, + { + "id": "EVT-DEC09-001", + "title": "Herreklipning", + "description": "Klassisk herreklip", + "start": "2025-12-09T11:00:00", + "end": "2025-12-09T11:30:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-005", + "resourceId": "EMP003", + "customerId": "CUST005", + "syncStatus": "synced", + "metadata": { "duration": 30, "color": "teal" } + }, + { + "id": "EVT-DEC09-002", + "title": "Skæg trimning", + "description": "Skæg trim og styling", + "start": "2025-12-09T16:00:00", + "end": "2025-12-09T16:30:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-006", + "resourceId": "EMP003", + "customerId": "CUST006", + "syncStatus": "synced", + "metadata": { "duration": 30, "color": "cyan" } + }, + { + "id": "EVT-DEC09-003", + "title": "Bryllupsfrisure", + "description": "Bryllupsfrisure med prøve", + "start": "2025-12-09T08:00:00", + "end": "2025-12-09T10:00:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-007", + "resourceId": "EMP004", + "customerId": "CUST007", + "syncStatus": "synced", + "metadata": { "duration": 120, "color": "green" } + }, + { + "id": "EVT-DEC10-001", + "title": "Highlights", + "description": "Highlights med folie", + "start": "2025-12-10T12:00:00", + "end": "2025-12-10T14:00:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-008", + "resourceId": "EMP005", + "customerId": "CUST008", + "syncStatus": "synced", + "metadata": { "duration": 120, "color": "lime" } + }, + { + "id": "EVT-DEC10-002", + "title": "Styling konsultation", + "description": "Rådgivning om ny stil", + "start": "2025-12-10T15:00:00", + "end": "2025-12-10T15:30:00", + "type": "meeting", + "allDay": false, + "resourceId": "EMP005", + "syncStatus": "synced", + "metadata": { "duration": 30, "color": "amber" } + }, + { + "id": "EVT-DEC10-003", + "title": "Olaplex behandling", + "description": "Fuld Olaplex kur", + "start": "2025-12-10T09:00:00", + "end": "2025-12-10T10:30:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-009", + "resourceId": "EMP001", + "customerId": "CUST009", + "syncStatus": "synced", + "metadata": { "duration": 90, "color": "blue" } + }, + { + "id": "EVT-DEC11-001", + "title": "Extensions", + "description": "Hair extensions påsætning", + "start": "2025-12-11T09:00:00", + "end": "2025-12-11T12:00:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-010", + "resourceId": "EMP002", + "customerId": "CUST010", + "syncStatus": "synced", + "metadata": { "duration": 180, "color": "deep-purple" } + }, + { + "id": "EVT-DEC11-002", + "title": "Børneklip", + "description": "Klipning af barn", + "start": "2025-12-11T14:00:00", + "end": "2025-12-11T14:30:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-011", + "resourceId": "EMP003", + "customerId": "CUST011", + "syncStatus": "synced", + "metadata": { "duration": 30, "color": "light-blue" } + }, + { + "id": "EVT-DEC11-003", + "title": "Frisør møde", + "description": "Team møde", + "start": "2025-12-11T08:00:00", + "end": "2025-12-11T08:30:00", + "type": "meeting", + "allDay": false, + "resourceId": "EMP001", + "syncStatus": "synced", + "metadata": { "duration": 30, "color": "red" } + }, + { + "id": "EVT-DEC12-001", + "title": "Keratin behandling", + "description": "Brasiliansk keratin", + "start": "2025-12-12T10:00:00", + "end": "2025-12-12T13:00:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-012", + "resourceId": "EMP004", + "customerId": "CUST012", + "syncStatus": "synced", + "metadata": { "duration": 180, "color": "violet" } + }, + { + "id": "EVT-DEC12-002", + "title": "Vask og føn", + "description": "Express service", + "start": "2025-12-12T15:00:00", + "end": "2025-12-12T15:45:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-013", + "resourceId": "EMP005", + "customerId": "CUST013", + "syncStatus": "synced", + "metadata": { "duration": 45, "color": "light-green" } + }, + { + "id": "EVT-DEC12-003", + "title": "Farvekorrektion", + "description": "Korrektion af tidligere farve", + "start": "2025-12-12T09:00:00", + "end": "2025-12-12T12:00:00", + "type": "customer", + "allDay": false, + "bookingId": "BOOK-014", + "resourceId": "EMP001", + "customerId": "CUST014", + "syncStatus": "synced", + "metadata": { "duration": 180, "color": "magenta" } } ]