Moving away from Azure Devops #1
26 changed files with 2108 additions and 44 deletions
|
|
@ -11,7 +11,8 @@
|
||||||
"WebFetch(domain:github.com)",
|
"WebFetch(domain:github.com)",
|
||||||
"Bash(npm install:*)",
|
"Bash(npm install:*)",
|
||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"Bash(npm run css:analyze:*)"
|
"Bash(npm run css:analyze:*)",
|
||||||
|
"Bash(npm run test:run:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
323
.workbench/scenarios/v2-scenario-renderer.js
Normal file
323
.workbench/scenarios/v2-scenario-renderer.js
Normal 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 };
|
||||||
307
.workbench/scenarios/v2-scenarios.html
Normal file
307
.workbench/scenarios/v2-scenarios.html
Normal 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>
|
||||||
|
|
@ -31,12 +31,18 @@ import { BookingService } from './storage/bookings/BookingService';
|
||||||
import { CustomerStore } from './storage/customers/CustomerStore';
|
import { CustomerStore } from './storage/customers/CustomerStore';
|
||||||
import { CustomerService } from './storage/customers/CustomerService';
|
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
|
// Repositories
|
||||||
import { IApiRepository } from './repositories/IApiRepository';
|
import { IApiRepository } from './repositories/IApiRepository';
|
||||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||||
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||||
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||||
|
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||||
|
|
||||||
// Workers
|
// Workers
|
||||||
import { DataSeeder } from './workers/DataSeeder';
|
import { DataSeeder } from './workers/DataSeeder';
|
||||||
|
|
@ -55,6 +61,7 @@ import { ResourceScheduleService } from './storage/schedules/ResourceScheduleSer
|
||||||
import { DragDropManager } from './managers/DragDropManager';
|
import { DragDropManager } from './managers/DragDropManager';
|
||||||
import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
||||||
import { ResizeManager } from './managers/ResizeManager';
|
import { ResizeManager } from './managers/ResizeManager';
|
||||||
|
import { EventPersistenceManager } from './managers/EventPersistenceManager';
|
||||||
|
|
||||||
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
|
@ -68,7 +75,8 @@ const defaultGridConfig: IGridConfig = {
|
||||||
hourHeight: 64,
|
hourHeight: 64,
|
||||||
dayStartHour: 6,
|
dayStartHour: 6,
|
||||||
dayEndHour: 18,
|
dayEndHour: 18,
|
||||||
snapInterval: 15
|
snapInterval: 15,
|
||||||
|
gridStartThresholdMinutes: 30
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createV2Container(): Container {
|
export function createV2Container(): Container {
|
||||||
|
|
@ -95,6 +103,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(BookingStore).as<IStore>();
|
builder.registerType(BookingStore).as<IStore>();
|
||||||
builder.registerType(CustomerStore).as<IStore>();
|
builder.registerType(CustomerStore).as<IStore>();
|
||||||
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
builder.registerType(ScheduleOverrideStore).as<IStore>();
|
||||||
|
builder.registerType(AuditStore).as<IStore>();
|
||||||
|
|
||||||
// Entity services (for DataSeeder polymorphic array)
|
// Entity services (for DataSeeder polymorphic array)
|
||||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
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<ICustomer>>();
|
||||||
builder.registerType(MockCustomerRepository).as<IApiRepository<ISync>>();
|
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
|
// Workers
|
||||||
builder.registerType(DataSeeder).as<DataSeeder>();
|
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||||
|
|
||||||
|
|
@ -155,6 +170,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(DragDropManager).as<DragDropManager>();
|
builder.registerType(DragDropManager).as<DragDropManager>();
|
||||||
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
|
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
|
||||||
builder.registerType(ResizeManager).as<ResizeManager>();
|
builder.registerType(ResizeManager).as<ResizeManager>();
|
||||||
|
builder.registerType(EventPersistenceManager).as<EventPersistenceManager>();
|
||||||
|
|
||||||
// Demo app
|
// Demo app
|
||||||
builder.registerType(DemoApp).as<DemoApp>();
|
builder.registerType(DemoApp).as<DemoApp>();
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ export const CoreEvents = {
|
||||||
ENTITY_SAVED: 'entity:saved',
|
ENTITY_SAVED: 'entity:saved',
|
||||||
ENTITY_DELETED: 'entity:deleted',
|
ENTITY_DELETED: 'entity:deleted',
|
||||||
|
|
||||||
|
// Audit events
|
||||||
|
AUDIT_LOGGED: 'audit:logged',
|
||||||
|
|
||||||
// Rendering events
|
// Rendering events
|
||||||
EVENTS_RENDERED: 'events:rendered'
|
EVENTS_RENDERED: 'events:rendered'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ export interface IGridConfig {
|
||||||
dayStartHour: number; // e.g. 6
|
dayStartHour: number; // e.g. 6
|
||||||
dayEndHour: number; // e.g. 18
|
dayEndHour: number; // e.g. 18
|
||||||
snapInterval: number; // minutes, e.g. 15
|
snapInterval: number; // minutes, e.g. 15
|
||||||
|
gridStartThresholdMinutes?: number; // threshold for GRID grouping (default 10)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import { ViewConfig } from '../core/ViewConfig';
|
||||||
import { DragDropManager } from '../managers/DragDropManager';
|
import { DragDropManager } from '../managers/DragDropManager';
|
||||||
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
|
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
|
||||||
import { ResizeManager } from '../managers/ResizeManager';
|
import { ResizeManager } from '../managers/ResizeManager';
|
||||||
|
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
||||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||||
|
import { AuditService } from '../storage/audit/AuditService';
|
||||||
|
|
||||||
export class DemoApp {
|
export class DemoApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
|
|
@ -29,7 +31,9 @@ export class DemoApp {
|
||||||
private dragDropManager: DragDropManager,
|
private dragDropManager: DragDropManager,
|
||||||
private edgeScrollManager: EdgeScrollManager,
|
private edgeScrollManager: EdgeScrollManager,
|
||||||
private resizeManager: ResizeManager,
|
private resizeManager: ResizeManager,
|
||||||
private headerDrawerRenderer: HeaderDrawerRenderer
|
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||||
|
private eventPersistenceManager: EventPersistenceManager,
|
||||||
|
private auditService: AuditService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|
|
||||||
279
src/v2/features/event/EventLayoutEngine.ts
Normal file
279
src/v2/features/event/EventLayoutEngine.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
/**
|
||||||
|
* EventLayoutEngine - Simplified stacking/grouping algorithm for V2
|
||||||
|
*
|
||||||
|
* Supports two layout modes:
|
||||||
|
* - GRID: Events starting at same time rendered side-by-side
|
||||||
|
* - STACKING: Overlapping events with margin-left offset (15px per level)
|
||||||
|
*
|
||||||
|
* Simplified from V1: No prev/next chains, single-pass greedy algorithm
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||||
|
import { IGridConfig } from '../../core/IGridConfig';
|
||||||
|
import { calculateEventPosition } from '../../utils/PositionUtils';
|
||||||
|
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
|
||||||
|
* This matches Scenario 8: end===start is NOT overlap
|
||||||
|
*/
|
||||||
|
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
|
||||||
|
return a.start < b.end && a.end > b.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two events are within threshold for grid grouping.
|
||||||
|
* This includes:
|
||||||
|
* 1. Start-to-start: Events start within threshold of each other
|
||||||
|
* 2. End-to-start: One event starts within threshold before another ends
|
||||||
|
*/
|
||||||
|
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
|
||||||
|
const thresholdMs = thresholdMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
// Start-to-start: both events start within threshold
|
||||||
|
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
|
||||||
|
if (startToStartDiff <= thresholdMs) return true;
|
||||||
|
|
||||||
|
// End-to-start: one event starts within threshold before the other ends
|
||||||
|
// B starts within threshold before A ends
|
||||||
|
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
|
||||||
|
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
|
||||||
|
|
||||||
|
// A starts within threshold before B ends
|
||||||
|
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
|
||||||
|
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all events in a group start within threshold of each other
|
||||||
|
*/
|
||||||
|
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
|
||||||
|
if (events.length <= 1) return true;
|
||||||
|
|
||||||
|
// Find earliest and latest start times
|
||||||
|
let earliest = events[0].start.getTime();
|
||||||
|
let latest = events[0].start.getTime();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const time = event.start.getTime();
|
||||||
|
if (time < earliest) earliest = time;
|
||||||
|
if (time > latest) latest = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMinutes = (latest - earliest) / (1000 * 60);
|
||||||
|
return diffMinutes <= thresholdMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find groups of overlapping events (connected by overlap chain)
|
||||||
|
* Events are grouped if they overlap with any event in the group
|
||||||
|
*/
|
||||||
|
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||||
|
if (events.length === 0) return [];
|
||||||
|
|
||||||
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
const used = new Set<string>();
|
||||||
|
const groups: ICalendarEvent[][] = [];
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
if (used.has(event.id)) continue;
|
||||||
|
|
||||||
|
// Start a new group with this event
|
||||||
|
const group: ICalendarEvent[] = [event];
|
||||||
|
used.add(event.id);
|
||||||
|
|
||||||
|
// Expand group by finding all connected events (via overlap)
|
||||||
|
let expanded = true;
|
||||||
|
while (expanded) {
|
||||||
|
expanded = false;
|
||||||
|
for (const candidate of sorted) {
|
||||||
|
if (used.has(candidate.id)) continue;
|
||||||
|
|
||||||
|
// Check if candidate overlaps with any event in group
|
||||||
|
const connects = group.some(member => eventsOverlap(member, candidate));
|
||||||
|
|
||||||
|
if (connects) {
|
||||||
|
group.push(candidate);
|
||||||
|
used.add(candidate.id);
|
||||||
|
expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find grid candidates within a group - events connected via threshold chain
|
||||||
|
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
|
||||||
|
*/
|
||||||
|
function findGridCandidates(
|
||||||
|
events: ICalendarEvent[],
|
||||||
|
thresholdMinutes: number
|
||||||
|
): ICalendarEvent[][] {
|
||||||
|
if (events.length === 0) return [];
|
||||||
|
|
||||||
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
const used = new Set<string>();
|
||||||
|
const groups: ICalendarEvent[][] = [];
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
if (used.has(event.id)) continue;
|
||||||
|
|
||||||
|
const group: ICalendarEvent[] = [event];
|
||||||
|
used.add(event.id);
|
||||||
|
|
||||||
|
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
|
||||||
|
let expanded = true;
|
||||||
|
while (expanded) {
|
||||||
|
expanded = false;
|
||||||
|
for (const candidate of sorted) {
|
||||||
|
if (used.has(candidate.id)) continue;
|
||||||
|
|
||||||
|
const connects = group.some(member =>
|
||||||
|
eventsWithinThreshold(member, candidate, thresholdMinutes)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connects) {
|
||||||
|
group.push(candidate);
|
||||||
|
used.add(candidate.id);
|
||||||
|
expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate stack levels for overlapping events using greedy algorithm
|
||||||
|
* For each event: level = max(overlapping already-processed events) + 1
|
||||||
|
*/
|
||||||
|
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
|
||||||
|
const levels = new Map<string, number>();
|
||||||
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
let maxOverlappingLevel = -1;
|
||||||
|
|
||||||
|
// Find max level among overlapping events already processed
|
||||||
|
for (const [id, level] of levels) {
|
||||||
|
const other = events.find(e => e.id === id);
|
||||||
|
if (other && eventsOverlap(event, other)) {
|
||||||
|
maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
levels.set(event.id, maxOverlappingLevel + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocate events to columns for GRID layout using greedy algorithm
|
||||||
|
* Non-overlapping events can share a column to minimize total columns
|
||||||
|
*/
|
||||||
|
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||||
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
const columns: ICalendarEvent[][] = [];
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
// Find first column where event doesn't overlap with existing events
|
||||||
|
let placed = false;
|
||||||
|
for (const column of columns) {
|
||||||
|
const canFit = !column.some(e => eventsOverlap(event, e));
|
||||||
|
if (canFit) {
|
||||||
|
column.push(event);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No suitable column found, create new one
|
||||||
|
if (!placed) {
|
||||||
|
columns.push([event]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point: Calculate complete layout for a column's events
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Find overlap groups (events connected by overlap chain)
|
||||||
|
* 2. For each overlap group, find grid candidates (events within threshold chain)
|
||||||
|
* 3. If all events in overlap group form a single grid candidate → GRID mode
|
||||||
|
* 4. Otherwise → STACKING mode with calculated levels
|
||||||
|
*/
|
||||||
|
export function calculateColumnLayout(
|
||||||
|
events: ICalendarEvent[],
|
||||||
|
config: IGridConfig
|
||||||
|
): IColumnLayout {
|
||||||
|
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
|
||||||
|
|
||||||
|
const result: IColumnLayout = {
|
||||||
|
grids: [],
|
||||||
|
stacked: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length === 0) return result;
|
||||||
|
|
||||||
|
// Find all overlapping event groups
|
||||||
|
const overlapGroups = findOverlapGroups(events);
|
||||||
|
|
||||||
|
for (const overlapGroup of overlapGroups) {
|
||||||
|
if (overlapGroup.length === 1) {
|
||||||
|
// Single event - no grouping needed
|
||||||
|
result.stacked.push({
|
||||||
|
event: overlapGroup[0],
|
||||||
|
stackLevel: 0
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within this overlap group, find grid candidates (threshold-connected subgroups)
|
||||||
|
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
|
||||||
|
|
||||||
|
// Check if the ENTIRE overlap group forms a single grid candidate
|
||||||
|
// This happens when all events are connected via threshold chain
|
||||||
|
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
|
||||||
|
g.length > max.length ? g : max, gridSubgroups[0]);
|
||||||
|
|
||||||
|
if (largestGridCandidate.length === overlapGroup.length) {
|
||||||
|
// All events in overlap group are connected via threshold chain → GRID mode
|
||||||
|
const columns = allocateColumns(overlapGroup);
|
||||||
|
const earliest = overlapGroup.reduce((min, e) =>
|
||||||
|
e.start < min.start ? e : min, overlapGroup[0]);
|
||||||
|
const position = calculateEventPosition(earliest.start, earliest.end, config);
|
||||||
|
|
||||||
|
result.grids.push({
|
||||||
|
events: overlapGroup,
|
||||||
|
columns,
|
||||||
|
stackLevel: 0,
|
||||||
|
position: { top: position.top }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Not all events connected via threshold → STACKING mode
|
||||||
|
const levels = calculateStackLevels(overlapGroup);
|
||||||
|
for (const event of overlapGroup) {
|
||||||
|
result.stacked.push({
|
||||||
|
event,
|
||||||
|
stackLevel: levels.get(event.id) ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
35
src/v2/features/event/EventLayoutTypes.ts
Normal file
35
src/v2/features/event/EventLayoutTypes.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stack link metadata stored on event elements
|
||||||
|
* Simplified from V1: No prev/next chains - only stackLevel needed for rendering
|
||||||
|
*/
|
||||||
|
export interface IStackLink {
|
||||||
|
stackLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout result for a stacked event (overlapping events with margin offset)
|
||||||
|
*/
|
||||||
|
export interface IStackedEventLayout {
|
||||||
|
event: ICalendarEvent;
|
||||||
|
stackLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout result for a grid group (simultaneous events side-by-side)
|
||||||
|
*/
|
||||||
|
export interface IGridGroupLayout {
|
||||||
|
events: ICalendarEvent[];
|
||||||
|
columns: ICalendarEvent[][]; // Events grouped by column (non-overlapping within column)
|
||||||
|
stackLevel: number; // Stack level for entire group (if nested in another event)
|
||||||
|
position: { top: number }; // Top position of earliest event in pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete layout result for a column's events
|
||||||
|
*/
|
||||||
|
export interface IColumnLayout {
|
||||||
|
grids: IGridGroupLayout[];
|
||||||
|
stacked: IStackedEventLayout[];
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { ICalendarEvent, IEventBus } from '../../types/CalendarTypes';
|
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
|
||||||
import { EventService } from '../../storage/events/EventService';
|
import { EventService } from '../../storage/events/EventService';
|
||||||
import { DateService } from '../../core/DateService';
|
import { DateService } from '../../core/DateService';
|
||||||
import { IGridConfig } from '../../core/IGridConfig';
|
import { IGridConfig } from '../../core/IGridConfig';
|
||||||
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
|
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
|
||||||
import { CoreEvents } from '../../constants/CoreEvents';
|
import { CoreEvents } from '../../constants/CoreEvents';
|
||||||
import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragTypes';
|
import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragTypes';
|
||||||
|
import { calculateColumnLayout } from './EventLayoutEngine';
|
||||||
|
import { IGridGroupLayout } from './EventLayoutTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventRenderer - Renders calendar events to the DOM
|
* EventRenderer - Renders calendar events to the DOM
|
||||||
|
|
@ -15,19 +17,21 @@ import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragType
|
||||||
* - Event data retrieved via EventService when needed
|
* - Event data retrieved via EventService when needed
|
||||||
*/
|
*/
|
||||||
export class EventRenderer {
|
export class EventRenderer {
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private eventService: EventService,
|
private eventService: EventService,
|
||||||
private dateService: DateService,
|
private dateService: DateService,
|
||||||
private gridConfig: IGridConfig,
|
private gridConfig: IGridConfig,
|
||||||
private eventBus: IEventBus
|
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) => {
|
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
|
||||||
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
|
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
|
||||||
this.handleColumnChange(payload);
|
this.handleColumnChange(payload);
|
||||||
|
|
@ -37,6 +41,95 @@ export class EventRenderer {
|
||||||
const payload = (e as CustomEvent<IDragMovePayload>).detail;
|
const payload = (e as CustomEvent<IDragMovePayload>).detail;
|
||||||
this.updateDragTimestamp(payload);
|
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
|
* @param filter - Filter with 'date' and optionally 'resource' arrays
|
||||||
*/
|
*/
|
||||||
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
|
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
|
||||||
|
// Store container reference for later re-renders
|
||||||
|
this.container = container;
|
||||||
|
|
||||||
const visibleDates = filter['date'] || [];
|
const visibleDates = filter['date'] || [];
|
||||||
|
|
||||||
if (visibleDates.length === 0) return;
|
if (visibleDates.length === 0) return;
|
||||||
|
|
@ -142,12 +238,22 @@ export class EventRenderer {
|
||||||
// Clear existing events
|
// Clear existing events
|
||||||
eventsLayer.innerHTML = '';
|
eventsLayer.innerHTML = '';
|
||||||
|
|
||||||
// Render each timed event
|
// Filter to timed events only
|
||||||
columnEvents.forEach(event => {
|
const timedEvents = columnEvents.filter(event => !event.allDay);
|
||||||
if (!event.allDay) {
|
|
||||||
const eventElement = this.createEventElement(event);
|
// Calculate layout with stacking/grouping
|
||||||
eventsLayer!.appendChild(eventElement);
|
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 {
|
private createEventElement(event: ICalendarEvent): HTMLElement {
|
||||||
const element = document.createElement('swp-event');
|
const element = document.createElement('swp-event');
|
||||||
|
|
||||||
// Only essential data attribute
|
// Only essential data attribute (eventId for DragDropManager compatibility)
|
||||||
element.dataset.id = event.id;
|
element.dataset.eventId = event.id;
|
||||||
|
|
||||||
// Calculate position
|
// Calculate position
|
||||||
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
|
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 {
|
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> = {
|
const typeColors: Record<string, string> = {
|
||||||
'customer': 'is-blue',
|
'customer': 'is-blue',
|
||||||
'vacation': 'is-green',
|
'vacation': 'is-green',
|
||||||
|
|
@ -208,4 +320,70 @@ export class EventRenderer {
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a GRID group with side-by-side columns
|
||||||
|
* Used when multiple events start at the same time
|
||||||
|
*/
|
||||||
|
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
|
||||||
|
const group = document.createElement('swp-event-group');
|
||||||
|
group.classList.add(`cols-${layout.columns.length}`);
|
||||||
|
group.style.top = `${layout.position.top}px`;
|
||||||
|
|
||||||
|
// Stack level styling for entire group (if nested in another event)
|
||||||
|
if (layout.stackLevel > 0) {
|
||||||
|
group.style.marginLeft = `${layout.stackLevel * 15}px`;
|
||||||
|
group.style.zIndex = `${100 + layout.stackLevel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the height needed for the group (tallest event)
|
||||||
|
let maxBottom = 0;
|
||||||
|
for (const event of layout.events) {
|
||||||
|
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
|
||||||
|
const eventBottom = pos.top + pos.height;
|
||||||
|
if (eventBottom > maxBottom) maxBottom = eventBottom;
|
||||||
|
}
|
||||||
|
const groupHeight = maxBottom - layout.position.top;
|
||||||
|
group.style.height = `${groupHeight}px`;
|
||||||
|
|
||||||
|
// Create wrapper div for each column
|
||||||
|
layout.columns.forEach(columnEvents => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.style.position = 'relative';
|
||||||
|
|
||||||
|
columnEvents.forEach(event => {
|
||||||
|
const eventEl = this.createEventElement(event);
|
||||||
|
// Position relative to group top
|
||||||
|
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
|
||||||
|
eventEl.style.top = `${pos.top - layout.position.top}px`;
|
||||||
|
eventEl.style.position = 'absolute';
|
||||||
|
eventEl.style.left = '0';
|
||||||
|
eventEl.style.right = '0';
|
||||||
|
wrapper.appendChild(eventEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.appendChild(wrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a STACKED event with margin-left offset
|
||||||
|
* Used for overlapping events that don't start at the same time
|
||||||
|
*/
|
||||||
|
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
|
||||||
|
const element = this.createEventElement(event);
|
||||||
|
|
||||||
|
// Add stack metadata for drag-drop and other features
|
||||||
|
element.dataset.stackLink = JSON.stringify({ stackLevel });
|
||||||
|
|
||||||
|
// Visual styling based on stack level
|
||||||
|
if (stackLevel > 0) {
|
||||||
|
element.style.marginLeft = `${stackLevel * 15}px`;
|
||||||
|
element.style.zIndex = `${100 + stackLevel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export class HeaderDrawerRenderer {
|
||||||
|
|
||||||
// Create header item
|
// Create header item
|
||||||
const item = document.createElement('swp-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.itemType = payload.itemType;
|
||||||
item.dataset.date = payload.sourceDate;
|
item.dataset.date = payload.sourceDate;
|
||||||
item.dataset.duration = String(payload.duration);
|
item.dataset.duration = String(payload.duration);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ interface DragState {
|
||||||
targetY: number;
|
targetY: number;
|
||||||
currentY: number;
|
currentY: number;
|
||||||
animationId: 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
|
// Remove ghost
|
||||||
this.dragState.ghostElement.remove();
|
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 dateKey = this.dragState.columnElement.dataset.date || '';
|
||||||
const resourceId = this.dragState.columnElement.dataset.resourceId;
|
const resourceId = this.dragState.columnElement.dataset.resourceId;
|
||||||
|
|
||||||
|
|
@ -155,7 +157,9 @@ export class DragDropManager {
|
||||||
snappedY,
|
snappedY,
|
||||||
columnElement: this.dragState.columnElement,
|
columnElement: this.dragState.columnElement,
|
||||||
dateKey,
|
dateKey,
|
||||||
resourceId
|
resourceId,
|
||||||
|
sourceDateKey: this.dragState.sourceDateKey,
|
||||||
|
sourceResourceId: this.dragState.sourceResourceId
|
||||||
};
|
};
|
||||||
|
|
||||||
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
|
||||||
|
|
@ -200,7 +204,9 @@ export class DragDropManager {
|
||||||
currentColumn: columnElement,
|
currentColumn: columnElement,
|
||||||
targetY: Math.max(0, targetY),
|
targetY: Math.max(0, targetY),
|
||||||
currentY: startY,
|
currentY: startY,
|
||||||
animationId: 0
|
animationId: 0,
|
||||||
|
sourceDateKey: columnElement.dataset.date || '',
|
||||||
|
sourceResourceId: columnElement.dataset.resourceId
|
||||||
};
|
};
|
||||||
|
|
||||||
// Emit drag:start
|
// Emit drag:start
|
||||||
|
|
|
||||||
116
src/v2/managers/EventPersistenceManager.ts
Normal file
116
src/v2/managers/EventPersistenceManager.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* EventPersistenceManager - Persists event changes to IndexedDB
|
||||||
|
*
|
||||||
|
* Listens to drag/resize events and updates IndexedDB via EventService.
|
||||||
|
* This bridges the gap between UI interactions and data persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../types/CalendarTypes';
|
||||||
|
import { EventService } from '../storage/events/EventService';
|
||||||
|
import { IGridConfig } from '../core/IGridConfig';
|
||||||
|
import { DateService } from '../core/DateService';
|
||||||
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
|
import { IDragEndPayload } from '../types/DragTypes';
|
||||||
|
import { IResizeEndPayload } from '../types/ResizeTypes';
|
||||||
|
import { pixelsToMinutes } from '../utils/PositionUtils';
|
||||||
|
|
||||||
|
export class EventPersistenceManager {
|
||||||
|
constructor(
|
||||||
|
private eventService: EventService,
|
||||||
|
private eventBus: IEventBus,
|
||||||
|
private gridConfig: IGridConfig,
|
||||||
|
private dateService: DateService
|
||||||
|
) {
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners(): void {
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_DRAG_END, this.handleDragEnd);
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_RESIZE_END, this.handleResizeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle drag end - update event position in IndexedDB
|
||||||
|
*/
|
||||||
|
private handleDragEnd = async (e: Event): Promise<void> => {
|
||||||
|
const payload = (e as CustomEvent<IDragEndPayload>).detail;
|
||||||
|
|
||||||
|
// Get existing event
|
||||||
|
const event = await this.eventService.get(payload.eventId);
|
||||||
|
if (!event) {
|
||||||
|
console.warn(`EventPersistenceManager: Event ${payload.eventId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new start time from snappedY
|
||||||
|
const minutesFromDayStart = pixelsToMinutes(payload.snappedY, this.gridConfig);
|
||||||
|
const totalMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromDayStart;
|
||||||
|
|
||||||
|
// Preserve duration
|
||||||
|
const durationMs = event.end.getTime() - event.start.getTime();
|
||||||
|
|
||||||
|
// Create new dates with correct day from dateKey
|
||||||
|
const newStart = new Date(payload.dateKey);
|
||||||
|
newStart.setHours(Math.floor(totalMinutes / 60), totalMinutes % 60, 0, 0);
|
||||||
|
const newEnd = new Date(newStart.getTime() + durationMs);
|
||||||
|
|
||||||
|
// Update and save
|
||||||
|
const updatedEvent: ICalendarEvent = {
|
||||||
|
...event,
|
||||||
|
start: newStart,
|
||||||
|
end: newEnd,
|
||||||
|
resourceId: payload.resourceId ?? event.resourceId,
|
||||||
|
syncStatus: 'pending'
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.eventService.save(updatedEvent);
|
||||||
|
|
||||||
|
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns
|
||||||
|
const updatePayload: IEventUpdatedPayload = {
|
||||||
|
eventId: updatedEvent.id,
|
||||||
|
sourceDateKey: payload.sourceDateKey,
|
||||||
|
sourceResourceId: payload.sourceResourceId,
|
||||||
|
targetDateKey: payload.dateKey,
|
||||||
|
targetResourceId: payload.resourceId
|
||||||
|
};
|
||||||
|
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle resize end - update event duration in IndexedDB
|
||||||
|
*/
|
||||||
|
private handleResizeEnd = async (e: Event): Promise<void> => {
|
||||||
|
const payload = (e as CustomEvent<IResizeEndPayload>).detail;
|
||||||
|
|
||||||
|
// Get existing event
|
||||||
|
const event = await this.eventService.get(payload.eventId);
|
||||||
|
if (!event) {
|
||||||
|
console.warn(`EventPersistenceManager: Event ${payload.eventId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new end time
|
||||||
|
const newEnd = new Date(event.start.getTime() + payload.newDurationMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
// Update and save
|
||||||
|
const updatedEvent: ICalendarEvent = {
|
||||||
|
...event,
|
||||||
|
end: newEnd,
|
||||||
|
syncStatus: 'pending'
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.eventService.save(updatedEvent);
|
||||||
|
|
||||||
|
// Emit EVENT_UPDATED for EventRenderer to re-render the column
|
||||||
|
// Resize stays in same column, so source and target are the same
|
||||||
|
const dateKey = this.dateService.getDateKey(event.start);
|
||||||
|
const updatePayload: IEventUpdatedPayload = {
|
||||||
|
eventId: updatedEvent.id,
|
||||||
|
sourceDateKey: dateKey,
|
||||||
|
sourceResourceId: event.resourceId,
|
||||||
|
targetDateKey: dateKey,
|
||||||
|
targetResourceId: event.resourceId
|
||||||
|
};
|
||||||
|
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -94,7 +94,7 @@ export class ResizeManager {
|
||||||
const element = handle.parentElement as HTMLElement;
|
const element = handle.parentElement as HTMLElement;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
const eventId = element.dataset.id || '';
|
const eventId = element.dataset.eventId || '';
|
||||||
const startHeight = element.offsetHeight;
|
const startHeight = element.offsetHeight;
|
||||||
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
|
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
|
||||||
|
|
||||||
|
|
|
||||||
49
src/v2/repositories/MockAuditRepository.ts
Normal file
49
src/v2/repositories/MockAuditRepository.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
import { IAuditEntry } from '../types/AuditTypes';
|
||||||
|
import { EntityType } from '../types/CalendarTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockAuditRepository - Mock API repository for audit entries
|
||||||
|
*
|
||||||
|
* In production, this would send audit entries to the backend.
|
||||||
|
* For development/testing, it just logs the operations.
|
||||||
|
*/
|
||||||
|
export class MockAuditRepository implements IApiRepository<IAuditEntry> {
|
||||||
|
readonly entityType: EntityType = 'Audit';
|
||||||
|
|
||||||
|
async sendCreate(entity: IAuditEntry): Promise<IAuditEntry> {
|
||||||
|
// Simulate API call delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
console.log('MockAuditRepository: Audit entry synced to backend:', {
|
||||||
|
id: entity.id,
|
||||||
|
entityType: entity.entityType,
|
||||||
|
entityId: entity.entityId,
|
||||||
|
operation: entity.operation,
|
||||||
|
timestamp: new Date(entity.timestamp).toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendUpdate(_id: string, _entity: IAuditEntry): Promise<IAuditEntry> {
|
||||||
|
// Audit entries are immutable - updates should not happen
|
||||||
|
throw new Error('Audit entries cannot be updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendDelete(_id: string): Promise<void> {
|
||||||
|
// Audit entries should never be deleted
|
||||||
|
throw new Error('Audit entries cannot be deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAll(): Promise<IAuditEntry[]> {
|
||||||
|
// For now, return empty array - audit entries are local-first
|
||||||
|
// In production, this could fetch audit history from backend
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchById(_id: string): Promise<IAuditEntry | null> {
|
||||||
|
// For now, return null - audit entries are local-first
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { IEntityService } from './IEntityService';
|
||||||
import { SyncPlugin } from './SyncPlugin';
|
import { SyncPlugin } from './SyncPlugin';
|
||||||
import { IndexedDBContext } from './IndexedDBContext';
|
import { IndexedDBContext } from './IndexedDBContext';
|
||||||
import { CoreEvents } from '../constants/CoreEvents';
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
|
import { diff } from 'json-diff-ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
|
* 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)
|
* 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 entityId = (entity as unknown as { id: string }).id;
|
||||||
const existingEntity = await this.get(entityId);
|
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);
|
const serialized = this.serialize(entity);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -100,12 +115,17 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
|
||||||
const request = store.put(serialized);
|
const request = store.put(serialized);
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
// Only emit event if not silent (silent used for seeding)
|
||||||
|
if (!silent) {
|
||||||
const payload: IEntitySavedPayload = {
|
const payload: IEntitySavedPayload = {
|
||||||
entityType: this.entityType,
|
entityType: this.entityType,
|
||||||
entity,
|
entityId,
|
||||||
isNew
|
operation: isCreate ? 'create' : 'update',
|
||||||
|
changes,
|
||||||
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
|
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
|
||||||
|
}
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -117,6 +137,7 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an entity
|
* Delete an entity
|
||||||
|
* Emits ENTITY_DELETED event
|
||||||
*/
|
*/
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -127,7 +148,9 @@ export abstract class BaseEntityService<T extends ISync> implements IEntityServi
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const payload: IEntityDeletedPayload = {
|
const payload: IEntityDeletedPayload = {
|
||||||
entityType: this.entityType,
|
entityType: this.entityType,
|
||||||
id
|
entityId: id,
|
||||||
|
operation: 'delete',
|
||||||
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
|
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
|
||||||
resolve();
|
resolve();
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ export interface IEntityService<T extends ISync> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save an entity (create or update) to IndexedDB
|
* 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
|
* Mark entity as successfully synced
|
||||||
|
|
|
||||||
167
src/v2/storage/audit/AuditService.ts
Normal file
167
src/v2/storage/audit/AuditService.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { BaseEntityService } from '../BaseEntityService';
|
||||||
|
import { IndexedDBContext } from '../IndexedDBContext';
|
||||||
|
import { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes';
|
||||||
|
import { EntityType, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../../types/CalendarTypes';
|
||||||
|
import { CoreEvents } from '../../constants/CoreEvents';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuditService - Entity service for audit entries
|
||||||
|
*
|
||||||
|
* RESPONSIBILITIES:
|
||||||
|
* - Store audit entries in IndexedDB
|
||||||
|
* - Listen for ENTITY_SAVED/ENTITY_DELETED events
|
||||||
|
* - Create audit entries for all entity changes
|
||||||
|
* - Emit AUDIT_LOGGED after saving (for SyncManager to listen)
|
||||||
|
*
|
||||||
|
* OVERRIDE PATTERN:
|
||||||
|
* - Overrides save() to NOT emit events (prevents infinite loops)
|
||||||
|
* - AuditService saves audit entries without triggering more audits
|
||||||
|
*
|
||||||
|
* EVENT CHAIN:
|
||||||
|
* Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager
|
||||||
|
*/
|
||||||
|
export class AuditService extends BaseEntityService<IAuditEntry> {
|
||||||
|
readonly storeName = 'audit';
|
||||||
|
readonly entityType: EntityType = 'Audit';
|
||||||
|
|
||||||
|
// Hardcoded userId for now - will come from session later
|
||||||
|
private static readonly DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
constructor(context: IndexedDBContext, eventBus: IEventBus) {
|
||||||
|
super(context, eventBus);
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup listeners for ENTITY_SAVED and ENTITY_DELETED events
|
||||||
|
*/
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
// Listen for entity saves (create/update)
|
||||||
|
this.eventBus.on(CoreEvents.ENTITY_SAVED, (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent).detail;
|
||||||
|
this.handleEntitySaved(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for entity deletes
|
||||||
|
this.eventBus.on(CoreEvents.ENTITY_DELETED, (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent).detail;
|
||||||
|
this.handleEntityDeleted(detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ENTITY_SAVED event - create audit entry
|
||||||
|
*/
|
||||||
|
private async handleEntitySaved(payload: IEntitySavedPayload): Promise<void> {
|
||||||
|
// Don't audit audit entries (prevent infinite loops)
|
||||||
|
if (payload.entityType === 'Audit') return;
|
||||||
|
|
||||||
|
const auditEntry: IAuditEntry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
entityType: payload.entityType,
|
||||||
|
entityId: payload.entityId,
|
||||||
|
operation: payload.operation,
|
||||||
|
userId: AuditService.DEFAULT_USER_ID,
|
||||||
|
timestamp: payload.timestamp,
|
||||||
|
changes: payload.changes,
|
||||||
|
synced: false,
|
||||||
|
syncStatus: 'pending'
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.save(auditEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ENTITY_DELETED event - create audit entry
|
||||||
|
*/
|
||||||
|
private async handleEntityDeleted(payload: IEntityDeletedPayload): Promise<void> {
|
||||||
|
// Don't audit audit entries (prevent infinite loops)
|
||||||
|
if (payload.entityType === 'Audit') return;
|
||||||
|
|
||||||
|
const auditEntry: IAuditEntry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
entityType: payload.entityType,
|
||||||
|
entityId: payload.entityId,
|
||||||
|
operation: 'delete',
|
||||||
|
userId: AuditService.DEFAULT_USER_ID,
|
||||||
|
timestamp: payload.timestamp,
|
||||||
|
changes: { id: payload.entityId }, // For delete, just store the ID
|
||||||
|
synced: false,
|
||||||
|
syncStatus: 'pending'
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.save(auditEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override save to NOT trigger ENTITY_SAVED event
|
||||||
|
* Instead, emits AUDIT_LOGGED for SyncManager to listen
|
||||||
|
*
|
||||||
|
* This prevents infinite loops:
|
||||||
|
* - BaseEntityService.save() emits ENTITY_SAVED
|
||||||
|
* - AuditService listens to ENTITY_SAVED and creates audit
|
||||||
|
* - If AuditService.save() also emitted ENTITY_SAVED, it would loop
|
||||||
|
*/
|
||||||
|
async save(entity: IAuditEntry): Promise<void> {
|
||||||
|
const serialized = this.serialize(entity);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const request = store.put(serialized);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
// Emit AUDIT_LOGGED instead of ENTITY_SAVED
|
||||||
|
const payload: IAuditLoggedPayload = {
|
||||||
|
auditId: entity.id,
|
||||||
|
entityType: entity.entityType,
|
||||||
|
entityId: entity.entityId,
|
||||||
|
operation: entity.operation,
|
||||||
|
timestamp: entity.timestamp
|
||||||
|
};
|
||||||
|
this.eventBus.emit(CoreEvents.AUDIT_LOGGED, payload);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to save audit entry ${entity.id}: ${request.error}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override delete to NOT trigger ENTITY_DELETED event
|
||||||
|
* Audit entries should never be deleted (compliance requirement)
|
||||||
|
*/
|
||||||
|
async delete(_id: string): Promise<void> {
|
||||||
|
throw new Error('Audit entries cannot be deleted (compliance requirement)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending audit entries (for sync)
|
||||||
|
*/
|
||||||
|
async getPendingAudits(): Promise<IAuditEntry[]> {
|
||||||
|
return this.getBySyncStatus('pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit entries for a specific entity
|
||||||
|
*/
|
||||||
|
async getByEntityId(entityId: string): Promise<IAuditEntry[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction([this.storeName], 'readonly');
|
||||||
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
const index = store.index('entityId');
|
||||||
|
const request = index.getAll(entityId);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const entries = request.result as IAuditEntry[];
|
||||||
|
resolve(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to get audit entries for entity ${entityId}: ${request.error}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/v2/storage/audit/AuditStore.ts
Normal file
27
src/v2/storage/audit/AuditStore.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { IStore } from '../IStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuditStore - IndexedDB store configuration for audit entries
|
||||||
|
*
|
||||||
|
* Stores all entity changes for:
|
||||||
|
* - Compliance and audit trail
|
||||||
|
* - Sync tracking with backend
|
||||||
|
* - Change history
|
||||||
|
*
|
||||||
|
* Indexes:
|
||||||
|
* - syncStatus: For finding pending entries to sync
|
||||||
|
* - synced: Boolean flag for quick sync queries
|
||||||
|
* - entityId: For getting all audits for a specific entity
|
||||||
|
* - timestamp: For chronological queries
|
||||||
|
*/
|
||||||
|
export class AuditStore implements IStore {
|
||||||
|
readonly storeName = 'audit';
|
||||||
|
|
||||||
|
create(db: IDBDatabase): void {
|
||||||
|
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
||||||
|
store.createIndex('syncStatus', 'syncStatus', { unique: false });
|
||||||
|
store.createIndex('synced', 'synced', { unique: false });
|
||||||
|
store.createIndex('entityId', 'entityId', { unique: false });
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/v2/types/AuditTypes.ts
Normal file
46
src/v2/types/AuditTypes.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { ISync, EntityType } from './CalendarTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAuditEntry - Audit log entry for tracking all entity changes
|
||||||
|
*
|
||||||
|
* Used for:
|
||||||
|
* - Compliance and audit trail
|
||||||
|
* - Sync tracking with backend
|
||||||
|
* - Change history
|
||||||
|
*/
|
||||||
|
export interface IAuditEntry extends ISync {
|
||||||
|
/** Unique audit entry ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Type of entity that was changed */
|
||||||
|
entityType: EntityType;
|
||||||
|
|
||||||
|
/** ID of the entity that was changed */
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
/** Type of operation performed */
|
||||||
|
operation: 'create' | 'update' | 'delete';
|
||||||
|
|
||||||
|
/** User who made the change */
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
/** Timestamp when change was made */
|
||||||
|
timestamp: number;
|
||||||
|
|
||||||
|
/** Changes made (full entity for create, diff for update, { id } for delete) */
|
||||||
|
changes: unknown;
|
||||||
|
|
||||||
|
/** Whether this audit entry has been synced to backend */
|
||||||
|
synced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAuditLoggedPayload - Event payload when audit entry is logged
|
||||||
|
*/
|
||||||
|
export interface IAuditLoggedPayload {
|
||||||
|
auditId: string;
|
||||||
|
entityType: EntityType;
|
||||||
|
entityId: string;
|
||||||
|
operation: 'create' | 'update' | 'delete';
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
@ -78,13 +78,26 @@ export interface IEventBus {
|
||||||
// Entity event payloads
|
// Entity event payloads
|
||||||
export interface IEntitySavedPayload {
|
export interface IEntitySavedPayload {
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
entity: ISync;
|
entityId: string;
|
||||||
isNew: boolean;
|
operation: 'create' | 'update';
|
||||||
|
changes: unknown;
|
||||||
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEntityDeletedPayload {
|
export interface IEntityDeletedPayload {
|
||||||
entityType: EntityType;
|
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
|
// Resource types
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,10 @@ export interface IDragEndPayload {
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
snappedY: number; // Final snapped position
|
snappedY: number; // Final snapped position
|
||||||
columnElement: HTMLElement;
|
columnElement: HTMLElement;
|
||||||
dateKey: string; // From column dataset
|
dateKey: string; // Target column date (from dataset)
|
||||||
resourceId?: string; // From column dataset (resource mode)
|
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 {
|
export interface IDragCancelPayload {
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export class DataSeeder {
|
||||||
console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`);
|
console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`);
|
||||||
|
|
||||||
for (const entity of data) {
|
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)`);
|
console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`);
|
||||||
|
|
|
||||||
258
test/v2/EventLayoutEngine.test.ts
Normal file
258
test/v2/EventLayoutEngine.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,5 +5,7 @@ export default defineConfig({
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./test/setup.ts'],
|
setupFiles: ['./test/setup.ts'],
|
||||||
globals: true,
|
globals: true,
|
||||||
|
include: ['test/v2/**/*.test.ts'],
|
||||||
|
exclude: ['test/managers/**', 'test/utils/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1,16 +1,222 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "RES-DEC08-001",
|
"id": "EVT-DEC08-001",
|
||||||
"title": "Balayage",
|
"title": "Balayage langt hår",
|
||||||
"description": "Test event for V2 rendering",
|
"description": "Fuld balayage behandling",
|
||||||
"start": "2025-12-08T09:00:00Z",
|
"start": "2025-12-08T10:00:00",
|
||||||
"end": "2025-12-08T11:00:00Z",
|
"end": "2025-12-08T11:00:00",
|
||||||
"type": "customer",
|
"type": "customer",
|
||||||
"allDay": false,
|
"allDay": false,
|
||||||
"bookingId": "BOOK-DEC08-001",
|
"bookingId": "BOOK-001",
|
||||||
"resourceId": "EMP001",
|
"resourceId": "EMP001",
|
||||||
"customerId": "CUST001",
|
"customerId": "CUST001",
|
||||||
"syncStatus": "synced",
|
"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" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue