Enhances event layout engine with advanced rendering logic

Introduces sophisticated event layout algorithm for handling complex scheduling scenarios

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

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

View file

@ -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": []

View file

@ -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 = `
<swp-event-time>${formatTime(event.start)} - ${formatTime(event.end)}</swp-event-time>
<swp-event-title>${event.title}</swp-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 };

View file

@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>V2 Event Layout Engine - All Scenarios</title>
<link rel="stylesheet" href="scenario-styles.css">
<style>
.scenarios-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-top: 20px;
}
.scenario-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
}
.scenario-card h3 {
margin-top: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.calendar-column {
height: 600px;
margin-top: 10px;
}
.summary {
background: #f0f0f0;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.summary h2 {
margin-top: 0;
}
.pass-count { color: #4caf50; font-weight: bold; }
.fail-count { color: #f44336; font-weight: bold; }
.expected-info {
font-size: 12px;
color: #666;
background: #f8f9fa;
padding: 8px;
border-radius: 4px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="scenario-container" style="max-width: 1600px;">
<h1>V2 Event Layout Engine - Visual Tests</h1>
<div class="summary">
<h2>Test Summary</h2>
<p>
<span class="pass-count" id="pass-count">0</span> passed,
<span class="fail-count" id="fail-count">0</span> failed
</p>
<p>Using V2 EventLayoutEngine with gridStartThresholdMinutes: 30</p>
</div>
<div class="scenarios-grid" id="scenarios-container">
<!-- Scenarios will be rendered here -->
</div>
</div>
<script type="module">
import { renderScenario, calculateColumnLayout, gridConfig } from './v2-scenario-renderer.js';
import { ScenarioTestRunner } from './scenario-test-runner.js';
// ============================================
// Scenario Definitions
// ============================================
const scenarios = [
{
id: 'scenario-1',
title: 'Scenario 1: No Overlap',
description: 'Three sequential events with no time overlap',
events: [
{ id: 'S1A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S1B', title: 'Event B', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S1C', title: 'Event C', start: new Date('2025-10-06T12:00'), end: new Date('2025-10-06T13:00') }
],
expected: [
{ eventId: 'S1A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1B', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1C', stackLevel: 0, type: 'stacked' }
]
},
{
id: 'scenario-2',
title: 'Scenario 2: Column Sharing (Grid)',
description: 'Two events starting at exactly the same time',
events: [
{ id: 'S2A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S2B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') }
],
expected: [
{ eventId: 'S2A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S2B', stackLevel: 0, cols: 2, type: 'grid' }
]
},
{
id: 'scenario-3',
title: 'Scenario 3: Nested Stacking',
description: 'Progressive nesting: A contains B, B contains C, C overlaps D',
events: [
{ id: 'S3A', title: 'Event A', start: new Date('2025-10-06T09:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S3B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T13:00') },
{ id: 'S3C', title: 'Event C', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S3D', title: 'Event D', start: new Date('2025-10-06T12:30'), end: new Date('2025-10-06T13:30') }
],
expected: [
{ eventId: 'S3A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S3B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S3C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S3D', stackLevel: 2, type: 'stacked' }
]
},
{
id: 'scenario-4',
title: 'Scenario 4: Complex Stacking',
description: 'Long event A with nested B, C, D at different times',
events: [
{ id: 'S4A', title: 'Event A', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T20:00') },
{ id: 'S4B', title: 'Event B', start: new Date('2025-10-06T15:00'), end: new Date('2025-10-06T17:00') },
{ id: 'S4C', title: 'Event C', start: new Date('2025-10-06T15:30'), end: new Date('2025-10-06T16:30') },
{ id: 'S4D', title: 'Event D', start: new Date('2025-10-06T18:00'), end: new Date('2025-10-06T19:00') }
],
expected: [
{ eventId: 'S4A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S4B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S4C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S4D', stackLevel: 1, type: 'stacked' }
]
},
{
id: 'scenario-5',
title: 'Scenario 5: Three Column Share',
description: 'Three events all starting at the same time',
events: [
{ id: 'S5A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S5B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S5C', title: 'Event C', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') }
],
expected: [
{ eventId: 'S5A', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5B', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5C', stackLevel: 0, cols: 3, type: 'grid' }
]
},
{
id: 'scenario-6',
title: 'Scenario 6: Overlapping Pairs',
description: 'Two independent pairs of overlapping events',
events: [
{ id: 'S6A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S6B', title: 'Event B', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S6C', title: 'Event C', start: new Date('2025-10-06T13:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S6D', title: 'Event D', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') }
],
expected: [
{ eventId: 'S6A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S6C', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6D', stackLevel: 1, type: 'stacked' }
]
},
{
id: 'scenario-7',
title: 'Scenario 7: Long Event Container',
description: 'Long event containing two non-overlapping events',
events: [
{ id: 'S7A', title: 'Event A', start: new Date('2025-10-06T09:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S7B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S7C', title: 'Event C', start: new Date('2025-10-06T12:00'), end: new Date('2025-10-06T13:00') }
],
expected: [
{ eventId: 'S7A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S7B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S7C', stackLevel: 1, type: 'stacked' }
]
},
{
id: 'scenario-8',
title: 'Scenario 8: Edge-Adjacent Events',
description: 'Events that touch but do not overlap (end === start)',
events: [
{ id: 'S8A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S8B', title: 'Event B', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') }
],
expected: [
{ eventId: 'S8A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S8B', stackLevel: 0, type: 'stacked' }
]
},
{
id: 'scenario-9',
title: 'Scenario 9: End-to-Start Chain',
description: 'Events linked by conflict chain: A->B->C',
events: [
{ id: 'S9A', title: 'Event A', start: new Date('2025-10-06T12:00'), end: new Date('2025-10-06T13:00') },
{ id: 'S9B', title: 'Event B', start: new Date('2025-10-06T12:30'), end: new Date('2025-10-06T13:30') },
{ id: 'S9C', title: 'Event C', start: new Date('2025-10-06T13:15'), end: new Date('2025-10-06T15:00') }
],
expected: [
{ eventId: 'S9A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9B', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9C', stackLevel: 0, cols: 2, type: 'grid' }
]
},
{
id: 'scenario-10',
title: 'Scenario 10: Four Column Grid',
description: 'Four events all starting at the same time',
events: [
{ id: 'S10A', title: 'Event A', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S10B', title: 'Event B', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S10C', title: 'Event C', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S10D', title: 'Event D', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') }
],
expected: [
{ eventId: 'S10A', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10B', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10C', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10D', stackLevel: 0, cols: 4, type: 'grid' }
]
}
];
// ============================================
// Render All Scenarios
// ============================================
const container = document.getElementById('scenarios-container');
let passCount = 0;
let failCount = 0;
scenarios.forEach(scenario => {
// Create card
const card = document.createElement('div');
card.className = 'scenario-card';
card.innerHTML = `
<h3>
${scenario.title}
<span class="test-badge test-pending" id="badge-${scenario.id}">Testing...</span>
</h3>
<p>${scenario.description}</p>
<div class="expected-info">
Expected: ${scenario.expected.map(e =>
e.type === 'grid'
? `${e.eventId} (grid cols-${e.cols})`
: `${e.eventId} (level ${e.stackLevel})`
).join(', ')}
</div>
<div class="calendar-column" id="column-${scenario.id}"></div>
<div id="results-${scenario.id}"></div>
`;
container.appendChild(card);
// Render events
const columnEl = document.getElementById(`column-${scenario.id}`);
renderScenario(columnEl, scenario.events);
// Run validation
const results = ScenarioTestRunner.validateScenario(scenario.id, scenario.expected);
// Update badge
const badge = document.getElementById(`badge-${scenario.id}`);
badge.className = `test-badge ${results.passed ? 'test-passed' : 'test-failed'}`;
badge.textContent = results.passed ? 'PASSED' : 'FAILED';
if (results.passed) {
passCount++;
} else {
failCount++;
}
// Show detailed results if failed
if (!results.passed) {
const resultsEl = document.getElementById(`results-${scenario.id}`);
resultsEl.innerHTML = '<div class="test-details"><h4>Failed checks:</h4>' +
results.results
.filter(r => !r.passed)
.map(r => `<div class="test-result-line failed">${r.eventId}: ${r.message}</div>`)
.join('') +
'</div>';
}
});
// Update summary
document.getElementById('pass-count').textContent = passCount;
document.getElementById('fail-count').textContent = failCount;
</script>
</body>
</html>

View file

@ -31,12 +31,18 @@ import { BookingService } from './storage/bookings/BookingService';
import { CustomerStore } from './storage/customers/CustomerStore';
import { CustomerService } from './storage/customers/CustomerService';
// Audit
import { AuditStore } from './storage/audit/AuditStore';
import { AuditService } from './storage/audit/AuditService';
import { IAuditEntry } from './types/AuditTypes';
// Repositories
import { IApiRepository } from './repositories/IApiRepository';
import { MockEventRepository } from './repositories/MockEventRepository';
import { MockResourceRepository } from './repositories/MockResourceRepository';
import { MockBookingRepository } from './repositories/MockBookingRepository';
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
import { MockAuditRepository } from './repositories/MockAuditRepository';
// Workers
import { DataSeeder } from './workers/DataSeeder';
@ -55,6 +61,7 @@ import { ResourceScheduleService } from './storage/schedules/ResourceScheduleSer
import { DragDropManager } from './managers/DragDropManager';
import { EdgeScrollManager } from './managers/EdgeScrollManager';
import { ResizeManager } from './managers/ResizeManager';
import { EventPersistenceManager } from './managers/EventPersistenceManager';
const defaultTimeFormatConfig: ITimeFormatConfig = {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@ -68,7 +75,8 @@ const defaultGridConfig: IGridConfig = {
hourHeight: 64,
dayStartHour: 6,
dayEndHour: 18,
snapInterval: 15
snapInterval: 15,
gridStartThresholdMinutes: 30
};
export function createV2Container(): Container {
@ -95,6 +103,7 @@ export function createV2Container(): Container {
builder.registerType(BookingStore).as<IStore>();
builder.registerType(CustomerStore).as<IStore>();
builder.registerType(ScheduleOverrideStore).as<IStore>();
builder.registerType(AuditStore).as<IStore>();
// Entity services (for DataSeeder polymorphic array)
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
@ -126,6 +135,12 @@ export function createV2Container(): Container {
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
builder.registerType(MockCustomerRepository).as<IApiRepository<ISync>>();
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
builder.registerType(MockAuditRepository).as<IApiRepository<ISync>>();
// Audit service (listens to ENTITY_SAVED/DELETED events automatically)
builder.registerType(AuditService).as<AuditService>();
// Workers
builder.registerType(DataSeeder).as<DataSeeder>();
@ -155,6 +170,7 @@ export function createV2Container(): Container {
builder.registerType(DragDropManager).as<DragDropManager>();
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
builder.registerType(ResizeManager).as<ResizeManager>();
builder.registerType(EventPersistenceManager).as<EventPersistenceManager>();
// Demo app
builder.registerType(DemoApp).as<DemoApp>();

View file

@ -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;

View file

@ -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)
}

View file

@ -10,7 +10,9 @@ import { ViewConfig } from '../core/ViewConfig';
import { DragDropManager } from '../managers/DragDropManager';
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
import { ResizeManager } from '../managers/ResizeManager';
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
import { AuditService } from '../storage/audit/AuditService';
export class DemoApp {
private animator!: NavigationAnimator;
@ -29,7 +31,9 @@ export class DemoApp {
private dragDropManager: DragDropManager,
private edgeScrollManager: EdgeScrollManager,
private resizeManager: ResizeManager,
private headerDrawerRenderer: HeaderDrawerRenderer
private headerDrawerRenderer: HeaderDrawerRenderer,
private eventPersistenceManager: EventPersistenceManager,
private auditService: AuditService
) {}
async init(): Promise<void> {

View file

@ -0,0 +1,279 @@
/**
* EventLayoutEngine - Simplified stacking/grouping algorithm for V2
*
* Supports two layout modes:
* - GRID: Events starting at same time rendered side-by-side
* - STACKING: Overlapping events with margin-left offset (15px per level)
*
* Simplified from V1: No prev/next chains, single-pass greedy algorithm
*/
import { ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition } from '../../utils/PositionUtils';
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
/**
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
* This matches Scenario 8: end===start is NOT overlap
*/
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
return a.start < b.end && a.end > b.start;
}
/**
* Check if two events are within threshold for grid grouping.
* This includes:
* 1. Start-to-start: Events start within threshold of each other
* 2. End-to-start: One event starts within threshold before another ends
*/
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
const thresholdMs = thresholdMinutes * 60 * 1000;
// Start-to-start: both events start within threshold
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
if (startToStartDiff <= thresholdMs) return true;
// End-to-start: one event starts within threshold before the other ends
// B starts within threshold before A ends
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
// A starts within threshold before B ends
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
return false;
}
/**
* Check if all events in a group start within threshold of each other
*/
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
if (events.length <= 1) return true;
// Find earliest and latest start times
let earliest = events[0].start.getTime();
let latest = events[0].start.getTime();
for (const event of events) {
const time = event.start.getTime();
if (time < earliest) earliest = time;
if (time > latest) latest = time;
}
const diffMinutes = (latest - earliest) / (1000 * 60);
return diffMinutes <= thresholdMinutes;
}
/**
* Find groups of overlapping events (connected by overlap chain)
* Events are grouped if they overlap with any event in the group
*/
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
// Start a new group with this event
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand group by finding all connected events (via overlap)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
// Check if candidate overlaps with any event in group
const connects = group.some(member => eventsOverlap(member, candidate));
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Find grid candidates within a group - events connected via threshold chain
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
*/
function findGridCandidates(
events: ICalendarEvent[],
thresholdMinutes: number
): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
const connects = group.some(member =>
eventsWithinThreshold(member, candidate, thresholdMinutes)
);
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Calculate stack levels for overlapping events using greedy algorithm
* For each event: level = max(overlapping already-processed events) + 1
*/
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
const levels = new Map<string, number>();
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
for (const event of sorted) {
let maxOverlappingLevel = -1;
// Find max level among overlapping events already processed
for (const [id, level] of levels) {
const other = events.find(e => e.id === id);
if (other && eventsOverlap(event, other)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
}
}
levels.set(event.id, maxOverlappingLevel + 1);
}
return levels;
}
/**
* Allocate events to columns for GRID layout using greedy algorithm
* Non-overlapping events can share a column to minimize total columns
*/
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const columns: ICalendarEvent[][] = [];
for (const event of sorted) {
// Find first column where event doesn't overlap with existing events
let placed = false;
for (const column of columns) {
const canFit = !column.some(e => eventsOverlap(event, e));
if (canFit) {
column.push(event);
placed = true;
break;
}
}
// No suitable column found, create new one
if (!placed) {
columns.push([event]);
}
}
return columns;
}
/**
* Main entry point: Calculate complete layout for a column's events
*
* Algorithm:
* 1. Find overlap groups (events connected by overlap chain)
* 2. For each overlap group, find grid candidates (events within threshold chain)
* 3. If all events in overlap group form a single grid candidate GRID mode
* 4. Otherwise STACKING mode with calculated levels
*/
export function calculateColumnLayout(
events: ICalendarEvent[],
config: IGridConfig
): IColumnLayout {
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
const result: IColumnLayout = {
grids: [],
stacked: []
};
if (events.length === 0) return result;
// Find all overlapping event groups
const overlapGroups = findOverlapGroups(events);
for (const overlapGroup of overlapGroups) {
if (overlapGroup.length === 1) {
// Single event - no grouping needed
result.stacked.push({
event: overlapGroup[0],
stackLevel: 0
});
continue;
}
// Within this overlap group, find grid candidates (threshold-connected subgroups)
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
// Check if the ENTIRE overlap group forms a single grid candidate
// This happens when all events are connected via threshold chain
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
g.length > max.length ? g : max, gridSubgroups[0]);
if (largestGridCandidate.length === overlapGroup.length) {
// All events in overlap group are connected via threshold chain → GRID mode
const columns = allocateColumns(overlapGroup);
const earliest = overlapGroup.reduce((min, e) =>
e.start < min.start ? e : min, overlapGroup[0]);
const position = calculateEventPosition(earliest.start, earliest.end, config);
result.grids.push({
events: overlapGroup,
columns,
stackLevel: 0,
position: { top: position.top }
});
} else {
// Not all events connected via threshold → STACKING mode
const levels = calculateStackLevels(overlapGroup);
for (const event of overlapGroup) {
result.stacked.push({
event,
stackLevel: levels.get(event.id) ?? 0
});
}
}
}
return result;
}

View file

@ -0,0 +1,35 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* Stack link metadata stored on event elements
* Simplified from V1: No prev/next chains - only stackLevel needed for rendering
*/
export interface IStackLink {
stackLevel: number;
}
/**
* Layout result for a stacked event (overlapping events with margin offset)
*/
export interface IStackedEventLayout {
event: ICalendarEvent;
stackLevel: number;
}
/**
* Layout result for a grid group (simultaneous events side-by-side)
*/
export interface IGridGroupLayout {
events: ICalendarEvent[];
columns: ICalendarEvent[][]; // Events grouped by column (non-overlapping within column)
stackLevel: number; // Stack level for entire group (if nested in another event)
position: { top: number }; // Top position of earliest event in pixels
}
/**
* Complete layout result for a column's events
*/
export interface IColumnLayout {
grids: IGridGroupLayout[];
stacked: IStackedEventLayout[];
}

View file

@ -1,10 +1,12 @@
import { ICalendarEvent, IEventBus } from '../../types/CalendarTypes';
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
import { CoreEvents } from '../../constants/CoreEvents';
import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragTypes';
import { calculateColumnLayout } from './EventLayoutEngine';
import { IGridGroupLayout } from './EventLayoutTypes';
/**
* EventRenderer - Renders calendar events to the DOM
@ -15,19 +17,21 @@ import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragType
* - Event data retrieved via EventService when needed
*/
export class EventRenderer {
private container: HTMLElement | null = null;
constructor(
private eventService: EventService,
private dateService: DateService,
private gridConfig: IGridConfig,
private eventBus: IEventBus
) {
this.setupDragListeners();
this.setupListeners();
}
/**
* Setup listeners for drag-drop events
* Setup listeners for drag-drop and update events
*/
private setupDragListeners(): void {
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
this.handleColumnChange(payload);
@ -37,6 +41,95 @@ export class EventRenderer {
const payload = (e as CustomEvent<IDragMovePayload>).detail;
this.updateDragTimestamp(payload);
});
this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => {
const payload = (e as CustomEvent<IEventUpdatedPayload>).detail;
this.handleEventUpdated(payload);
});
}
/**
* Handle EVENT_UPDATED - re-render affected columns
*/
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
// Re-render source column (if different from target)
if (payload.sourceDateKey !== payload.targetDateKey ||
payload.sourceResourceId !== payload.targetResourceId) {
await this.rerenderColumn(payload.sourceDateKey, payload.sourceResourceId);
}
// Re-render target column
await this.rerenderColumn(payload.targetDateKey, payload.targetResourceId);
}
/**
* Re-render a single column with fresh data from IndexedDB
*/
private async rerenderColumn(dateKey: string, resourceId?: string): Promise<void> {
const column = this.findColumn(dateKey, resourceId);
if (!column) return;
// Get date range for this day
const startDate = new Date(dateKey);
const endDate = new Date(dateKey);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = resourceId
? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate)
: await this.eventService.getByDateRange(startDate, endDate);
// Filter to timed events and match dateKey exactly
const timedEvents = events.filter(event =>
!event.allDay && this.dateService.getDateKey(event.start) === dateKey
);
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
}
/**
* Find a column element by dateKey and optional resourceId
*/
private findColumn(dateKey: string, resourceId?: string): HTMLElement | null {
if (!this.container) return null;
const columns = this.container.querySelectorAll('swp-day-column');
for (const col of columns) {
const colEl = col as HTMLElement;
if (colEl.dataset.date !== dateKey) continue;
// If resourceId specified, must match
if (resourceId && colEl.dataset.resourceId !== resourceId) continue;
// If no resourceId specified but column has one, skip (simple view case)
if (!resourceId && colEl.dataset.resourceId) continue;
return colEl;
}
return null;
}
/**
@ -93,6 +186,9 @@ export class EventRenderer {
* @param filter - Filter with 'date' and optionally 'resource' arrays
*/
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
// Store container reference for later re-renders
this.container = container;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
@ -142,12 +238,22 @@ export class EventRenderer {
// Clear existing events
eventsLayer.innerHTML = '';
// Render each timed event
columnEvents.forEach(event => {
if (!event.allDay) {
const eventElement = this.createEventElement(event);
eventsLayer!.appendChild(eventElement);
}
// Filter to timed events only
const timedEvents = columnEvents.filter(event => !event.allDay);
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups (simultaneous events side-by-side)
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events (overlapping with margin offset)
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
});
}
@ -162,8 +268,8 @@ export class EventRenderer {
private createEventElement(event: ICalendarEvent): HTMLElement {
const element = document.createElement('swp-event');
// Only essential data attribute
element.dataset.id = event.id;
// Only essential data attribute (eventId for DragDropManager compatibility)
element.dataset.eventId = event.id;
// Calculate position
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
@ -187,9 +293,15 @@ export class EventRenderer {
}
/**
* Get color class based on event type
* Get color class based on metadata.color or event type
*/
private getColorClass(event: ICalendarEvent): string {
// Check metadata.color first
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
// Fallback to type-based color
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
@ -208,4 +320,70 @@ export class EventRenderer {
div.textContent = text;
return div.innerHTML;
}
/**
* Render a GRID group with side-by-side columns
* Used when multiple events start at the same time
*/
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
const group = document.createElement('swp-event-group');
group.classList.add(`cols-${layout.columns.length}`);
group.style.top = `${layout.position.top}px`;
// Stack level styling for entire group (if nested in another event)
if (layout.stackLevel > 0) {
group.style.marginLeft = `${layout.stackLevel * 15}px`;
group.style.zIndex = `${100 + layout.stackLevel}`;
}
// Calculate the height needed for the group (tallest event)
let maxBottom = 0;
for (const event of layout.events) {
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
const eventBottom = pos.top + pos.height;
if (eventBottom > maxBottom) maxBottom = eventBottom;
}
const groupHeight = maxBottom - layout.position.top;
group.style.height = `${groupHeight}px`;
// Create wrapper div for each column
layout.columns.forEach(columnEvents => {
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
columnEvents.forEach(event => {
const eventEl = this.createEventElement(event);
// Position relative to group top
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
eventEl.style.top = `${pos.top - layout.position.top}px`;
eventEl.style.position = 'absolute';
eventEl.style.left = '0';
eventEl.style.right = '0';
wrapper.appendChild(eventEl);
});
group.appendChild(wrapper);
});
return group;
}
/**
* Render a STACKED event with margin-left offset
* Used for overlapping events that don't start at the same time
*/
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
const element = this.createEventElement(event);
// Add stack metadata for drag-drop and other features
element.dataset.stackLink = JSON.stringify({ stackLevel });
// Visual styling based on stack level
if (stackLevel > 0) {
element.style.marginLeft = `${stackLevel * 15}px`;
element.style.zIndex = `${100 + stackLevel}`;
}
return element;
}
}

View file

@ -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);

View file

@ -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

View file

@ -0,0 +1,116 @@
/**
* EventPersistenceManager - Persists event changes to IndexedDB
*
* Listens to drag/resize events and updates IndexedDB via EventService.
* This bridges the gap between UI interactions and data persistence.
*/
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../types/CalendarTypes';
import { EventService } from '../storage/events/EventService';
import { IGridConfig } from '../core/IGridConfig';
import { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IDragEndPayload } from '../types/DragTypes';
import { IResizeEndPayload } from '../types/ResizeTypes';
import { pixelsToMinutes } from '../utils/PositionUtils';
export class EventPersistenceManager {
constructor(
private eventService: EventService,
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private dateService: DateService
) {
this.setupListeners();
}
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_END, this.handleDragEnd);
this.eventBus.on(CoreEvents.EVENT_RESIZE_END, this.handleResizeEnd);
}
/**
* Handle drag end - update event position in IndexedDB
*/
private handleDragEnd = async (e: Event): Promise<void> => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
// Get existing event
const event = await this.eventService.get(payload.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${payload.eventId} not found`);
return;
}
// Calculate new start time from snappedY
const minutesFromDayStart = pixelsToMinutes(payload.snappedY, this.gridConfig);
const totalMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromDayStart;
// Preserve duration
const durationMs = event.end.getTime() - event.start.getTime();
// Create new dates with correct day from dateKey
const newStart = new Date(payload.dateKey);
newStart.setHours(Math.floor(totalMinutes / 60), totalMinutes % 60, 0, 0);
const newEnd = new Date(newStart.getTime() + durationMs);
// Update and save
const updatedEvent: ICalendarEvent = {
...event,
start: newStart,
end: newEnd,
resourceId: payload.resourceId ?? event.resourceId,
syncStatus: 'pending'
};
await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns
const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceDateKey: payload.sourceDateKey,
sourceResourceId: payload.sourceResourceId,
targetDateKey: payload.dateKey,
targetResourceId: payload.resourceId
};
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
};
/**
* Handle resize end - update event duration in IndexedDB
*/
private handleResizeEnd = async (e: Event): Promise<void> => {
const payload = (e as CustomEvent<IResizeEndPayload>).detail;
// Get existing event
const event = await this.eventService.get(payload.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${payload.eventId} not found`);
return;
}
// Calculate new end time
const newEnd = new Date(event.start.getTime() + payload.newDurationMinutes * 60 * 1000);
// Update and save
const updatedEvent: ICalendarEvent = {
...event,
end: newEnd,
syncStatus: 'pending'
};
await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render the column
// Resize stays in same column, so source and target are the same
const dateKey = this.dateService.getDateKey(event.start);
const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceDateKey: dateKey,
sourceResourceId: event.resourceId,
targetDateKey: dateKey,
targetResourceId: event.resourceId
};
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
};
}

View file

@ -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);

View file

@ -0,0 +1,49 @@
import { IApiRepository } from './IApiRepository';
import { IAuditEntry } from '../types/AuditTypes';
import { EntityType } from '../types/CalendarTypes';
/**
* MockAuditRepository - Mock API repository for audit entries
*
* In production, this would send audit entries to the backend.
* For development/testing, it just logs the operations.
*/
export class MockAuditRepository implements IApiRepository<IAuditEntry> {
readonly entityType: EntityType = 'Audit';
async sendCreate(entity: IAuditEntry): Promise<IAuditEntry> {
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 100));
console.log('MockAuditRepository: Audit entry synced to backend:', {
id: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
operation: entity.operation,
timestamp: new Date(entity.timestamp).toISOString()
});
return entity;
}
async sendUpdate(_id: string, _entity: IAuditEntry): Promise<IAuditEntry> {
// Audit entries are immutable - updates should not happen
throw new Error('Audit entries cannot be updated');
}
async sendDelete(_id: string): Promise<void> {
// Audit entries should never be deleted
throw new Error('Audit entries cannot be deleted');
}
async fetchAll(): Promise<IAuditEntry[]> {
// For now, return empty array - audit entries are local-first
// In production, this could fetch audit history from backend
return [];
}
async fetchById(_id: string): Promise<IAuditEntry | null> {
// For now, return null - audit entries are local-first
return null;
}
}

View file

@ -3,6 +3,7 @@ import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
import { IndexedDBContext } from './IndexedDBContext';
import { CoreEvents } from '../constants/CoreEvents';
import { diff } from 'json-diff-ts';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
@ -87,11 +88,25 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
/**
* Save an entity (create or update)
* Emits ENTITY_SAVED event with operation type and changes (diff for updates)
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
async save(entity: T): Promise<void> {
async save(entity: T, silent = false): Promise<void> {
const entityId = (entity as unknown as { id: string }).id;
const existingEntity = await this.get(entityId);
const isNew = existingEntity === null;
const isCreate = existingEntity === null;
// Calculate changes: full entity for create, diff for update
let changes: unknown;
if (isCreate) {
changes = entity;
} else {
const existingSerialized = this.serialize(existingEntity);
const newSerialized = this.serialize(entity);
changes = diff(existingSerialized, newSerialized);
}
const serialized = this.serialize(entity);
return new Promise((resolve, reject) => {
@ -100,12 +115,17 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
const request = store.put(serialized);
request.onsuccess = () => {
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entity,
isNew
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
// Only emit event if not silent (silent used for seeding)
if (!silent) {
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entityId,
operation: isCreate ? 'create' : 'update',
changes,
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
}
resolve();
};
@ -117,6 +137,7 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
/**
* Delete an entity
* Emits ENTITY_DELETED event
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
@ -127,7 +148,9 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
request.onsuccess = () => {
const payload: IEntityDeletedPayload = {
entityType: this.entityType,
id
entityId: id,
operation: 'delete',
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
resolve();

View file

@ -18,8 +18,10 @@ export interface IEntityService<T extends ISync> {
/**
* Save an entity (create or update) to IndexedDB
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
save(entity: T): Promise<void>;
save(entity: T, silent?: boolean): Promise<void>;
/**
* Mark entity as successfully synced

View file

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

View file

@ -0,0 +1,27 @@
import { IStore } from '../IStore';
/**
* AuditStore - IndexedDB store configuration for audit entries
*
* Stores all entity changes for:
* - Compliance and audit trail
* - Sync tracking with backend
* - Change history
*
* Indexes:
* - syncStatus: For finding pending entries to sync
* - synced: Boolean flag for quick sync queries
* - entityId: For getting all audits for a specific entity
* - timestamp: For chronological queries
*/
export class AuditStore implements IStore {
readonly storeName = 'audit';
create(db: IDBDatabase): void {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
store.createIndex('entityId', 'entityId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
}

View file

@ -0,0 +1,46 @@
import { ISync, EntityType } from './CalendarTypes';
/**
* IAuditEntry - Audit log entry for tracking all entity changes
*
* Used for:
* - Compliance and audit trail
* - Sync tracking with backend
* - Change history
*/
export interface IAuditEntry extends ISync {
/** Unique audit entry ID */
id: string;
/** Type of entity that was changed */
entityType: EntityType;
/** ID of the entity that was changed */
entityId: string;
/** Type of operation performed */
operation: 'create' | 'update' | 'delete';
/** User who made the change */
userId: string;
/** Timestamp when change was made */
timestamp: number;
/** Changes made (full entity for create, diff for update, { id } for delete) */
changes: unknown;
/** Whether this audit entry has been synced to backend */
synced: boolean;
}
/**
* IAuditLoggedPayload - Event payload when audit entry is logged
*/
export interface IAuditLoggedPayload {
auditId: string;
entityType: EntityType;
entityId: string;
operation: 'create' | 'update' | 'delete';
timestamp: number;
}

View file

@ -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

View file

@ -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 {

View file

@ -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)`);

View file

@ -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);
});
});
});

View file

@ -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/**'],
},
});

View file

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