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" }
}
]