This commit is contained in:
Janus C. H. Knudsen 2025-10-06 21:39:57 +02:00
parent faa59f6a3c
commit 69495ce00f
9 changed files with 337 additions and 1306 deletions

View file

@ -1,50 +0,0 @@
import { CalendarEvent } from '../types/CalendarTypes';
/**
* Base interface for all managers
*/
export interface IManager {
/**
* Initialize the manager
*/
initialize?(): Promise<void> | void;
/**
* Refresh the manager's state
*/
refresh?(): void;
}
/**
* Interface for managers that handle events
*/
export interface IEventManager extends IManager {
loadData(): Promise<void>;
getEvents(): CalendarEvent[];
getEventsForPeriod(startDate: Date, endDate: Date): CalendarEvent[];
}
/**
* Interface for managers that handle rendering
*/
export interface IRenderingManager extends IManager {
render(): Promise<void> | void;
}
/**
* Interface for managers that handle navigation
*/
export interface INavigationManager extends IManager {
getCurrentWeek(): Date;
navigateToToday(): void;
navigateToNextWeek(): void;
navigateToPreviousWeek(): void;
}
/**
* Interface for managers that handle scrolling
*/
export interface IScrollManager extends IManager {
scrollTo(scrollTop: number): void;
scrollToHour(hour: number): void;
}

View file

@ -1,473 +0,0 @@
/**
* SimpleEventOverlapManager - Clean, focused overlap management
* Eliminates complex state tracking in favor of direct DOM manipulation
*/
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
export enum OverlapType {
NONE = 'none',
COLUMN_SHARING = 'column_sharing',
STACKING = 'stacking'
}
export interface OverlapGroup {
type: OverlapType;
events: CalendarEvent[];
position: { top: number; height: number };
}
export interface StackLink {
prev?: string; // Event ID of previous event in stack
next?: string; // Event ID of next event in stack
stackLevel: number; // 0 = base event, 1 = first stacked, etc
}
export class SimpleEventOverlapManager {
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
/**
* Detect overlap type between two DOM elements - pixel-based logic
*/
public resolveOverlapType(element1: HTMLElement, element2: HTMLElement): OverlapType {
const top1 = parseInt(element1.style.top) || 0;
const height1 = parseInt(element1.style.height) || 0;
const bottom1 = top1 + height1;
const top2 = parseInt(element2.style.top) || 0;
const height2 = parseInt(element2.style.height) || 0;
const bottom2 = top2 + height2;
// Check if events overlap in pixel space
const tolerance = 2;
if (bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance)) {
return OverlapType.NONE;
}
// Events overlap - check start position difference for overlap type
const startDifference = Math.abs(top1 - top2);
// Over 40px start difference = stacking
if (startDifference > 40) {
return OverlapType.STACKING;
}
// Within 40px start difference = column sharing
return OverlapType.COLUMN_SHARING;
}
/**
* Group overlapping elements - pixel-based algorithm
*/
public groupOverlappingElements(elements: HTMLElement[]): HTMLElement[][] {
const groups: HTMLElement[][] = [];
const processed = new Set<HTMLElement>();
for (const element of elements) {
if (processed.has(element)) continue;
// Find all elements that overlap with this one
const overlapping = elements.filter(other => {
if (processed.has(other)) return false;
return other === element || this.resolveOverlapType(element, other) !== OverlapType.NONE;
});
// Mark all as processed
overlapping.forEach(e => processed.add(e));
groups.push(overlapping);
}
return groups;
}
/**
* Create flexbox container for column sharing - clean and simple
*/
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
const container = document.createElement('swp-event-group');
return container;
}
/**
* Add event to flexbox group - simple relative positioning
*/
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
// Set duration-based height
const duration = eventElement.dataset.duration;
if (duration) {
const durationMinutes = parseInt(duration);
const gridSettings = calendarConfig.getGridSettings();
const height = (durationMinutes / 60) * gridSettings.hourHeight;
eventElement.style.height = `${height - 3}px`;
}
// Flexbox styling
eventElement.style.position = 'relative';
eventElement.style.flex = '1';
eventElement.style.minWidth = '50px';
container.appendChild(eventElement);
}
/**
* Create stacked event with data-attribute tracking
*/
public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number): void {
const marginLeft = stackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
// Apply visual styling
eventElement.style.marginLeft = `${marginLeft}px`;
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.zIndex = `${100 + stackLevel}`;
// Set up stack linking via data attributes
const eventId = eventElement.dataset.eventId;
const underlyingId = underlyingElement.dataset.eventId;
if (!eventId || !underlyingId) {
console.warn('Missing event IDs for stack linking:', eventId, underlyingId);
return;
}
// Find the last event in the stack chain
let lastElement = underlyingElement;
let lastLink = this.getStackLink(lastElement);
// If underlying doesn't have stack link yet, create it
if (!lastLink) {
this.setStackLink(lastElement, { stackLevel: 0 });
lastLink = { stackLevel: 0 };
}
// Traverse to find the end of the chain
while (lastLink?.next) {
const nextElement = this.findElementById(lastLink.next);
if (!nextElement) break;
lastElement = nextElement;
lastLink = this.getStackLink(lastElement);
}
// Link the new event to the end of the chain
const lastElementId = lastElement.dataset.eventId!;
this.setStackLink(lastElement, {
...lastLink!,
next: eventId
});
this.setStackLink(eventElement, {
prev: lastElementId,
stackLevel: stackLevel
});
}
/**
* Remove stacked styling with proper stack re-linking
*/
public removeStackedStyling(eventElement: HTMLElement): void {
// Clear visual styling
eventElement.style.marginLeft = '';
eventElement.style.zIndex = '';
eventElement.style.left = '2px';
eventElement.style.right = '2px';
// Handle stack chain re-linking
const link = this.getStackLink(eventElement);
if (link) {
// Re-link prev and next events
if (link.prev && link.next) {
// Middle element - link prev to next
const prevElement = this.findElementById(link.prev);
const nextElement = this.findElementById(link.next);
if (prevElement && nextElement) {
const prevLink = this.getStackLink(prevElement);
const nextLink = this.getStackLink(nextElement);
// CRITICAL: Check if prev and next actually overlap without the middle element
const actuallyOverlap = this.resolveOverlapType(prevElement, nextElement);
if (!actuallyOverlap) {
// CHAIN BREAKING: prev and next don't overlap - break the chain
console.log('Breaking stack chain - events do not overlap directly');
// Prev element: remove next link (becomes end of its own chain)
this.setStackLink(prevElement, {
...prevLink!,
next: undefined
});
// Next element: becomes standalone (remove all stack links and styling)
this.setStackLink(nextElement, null);
nextElement.style.marginLeft = '';
nextElement.style.zIndex = '';
// If next element had subsequent events, they also become standalone
if (nextLink?.next) {
let subsequentId: string | undefined = nextLink.next;
while (subsequentId) {
const subsequentElement = this.findElementById(subsequentId);
if (!subsequentElement) break;
const subsequentLink = this.getStackLink(subsequentElement);
this.setStackLink(subsequentElement, null);
subsequentElement.style.marginLeft = '';
subsequentElement.style.zIndex = '';
subsequentId = subsequentLink?.next;
}
}
} else {
// NORMAL STACKING: they overlap, maintain the chain
this.setStackLink(prevElement, {
...prevLink!,
next: link.next
});
const correctStackLevel = (prevLink?.stackLevel ?? 0) + 1;
this.setStackLink(nextElement, {
...nextLink!,
prev: link.prev,
stackLevel: correctStackLevel
});
// Update visual styling to match new stackLevel
const marginLeft = correctStackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
nextElement.style.marginLeft = `${marginLeft}px`;
nextElement.style.zIndex = `${100 + correctStackLevel}`;
}
}
} else if (link.prev) {
// Last element - remove next link from prev
const prevElement = this.findElementById(link.prev);
if (prevElement) {
const prevLink = this.getStackLink(prevElement);
this.setStackLink(prevElement, {
...prevLink!,
next: undefined
});
}
} else if (link.next) {
// First element - remove prev link from next
const nextElement = this.findElementById(link.next);
if (nextElement) {
const nextLink = this.getStackLink(nextElement);
this.setStackLink(nextElement, {
...nextLink!,
prev: undefined,
stackLevel: 0 // Next becomes the base event
});
}
}
// Only update subsequent stack levels if we didn't break the chain
if (link.prev && link.next) {
const nextElement = this.findElementById(link.next);
const nextLink = nextElement ? this.getStackLink(nextElement) : null;
// If next element still has a stack link, the chain wasn't broken
if (nextLink && nextLink.next) {
this.updateSubsequentStackLevels(nextLink.next, -1);
}
// If nextLink is null, chain was broken - no subsequent updates needed
} else {
// First or last removal - update all subsequent
this.updateSubsequentStackLevels(link.next, -1);
}
// Clear this element's stack link
this.setStackLink(eventElement, null);
}
}
/**
* Update stack levels for all events following a given event ID
*/
private updateSubsequentStackLevels(startEventId: string | undefined, levelDelta: number): void {
let currentId = startEventId;
while (currentId) {
const currentElement = this.findElementById(currentId);
if (!currentElement) break;
const currentLink = this.getStackLink(currentElement);
if (!currentLink) break;
// Update stack level
const newLevel = Math.max(0, currentLink.stackLevel + levelDelta);
this.setStackLink(currentElement, {
...currentLink,
stackLevel: newLevel
});
// Update visual styling
const marginLeft = newLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
currentElement.style.marginLeft = `${marginLeft}px`;
currentElement.style.zIndex = `${100 + newLevel}`;
currentId = currentLink.next;
}
}
/**
* Check if element is stacked - check both style and data-stack-link
*/
public isStackedEvent(element: HTMLElement): boolean {
const marginLeft = element.style.marginLeft;
const hasMarginLeft = marginLeft !== '' && marginLeft !== '0px';
const hasStackLink = this.getStackLink(element) !== null;
return hasMarginLeft || hasStackLink;
}
/**
* Remove event from group with proper cleanup
*/
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
if (!eventElement) return false;
// Simply remove the element - no position calculation needed since it's being removed
eventElement.remove();
// Handle remaining events
const remainingEvents = container.querySelectorAll('swp-event');
const remainingCount = remainingEvents.length;
if (remainingCount === 0) {
container.remove();
return true;
}
if (remainingCount === 1) {
const remainingEvent = remainingEvents[0] as HTMLElement;
// Convert last event back to absolute positioning - use current pixel position
const currentTop = parseInt(remainingEvent.style.top) || 0;
remainingEvent.style.position = 'absolute';
remainingEvent.style.top = `${currentTop}px`;
remainingEvent.style.left = '2px';
remainingEvent.style.right = '2px';
remainingEvent.style.flex = '';
remainingEvent.style.minWidth = '';
container.parentElement?.insertBefore(remainingEvent, container);
container.remove();
return true;
}
return false;
}
/**
* Restack events in container - respects separate stack chains
*/
public restackEventsInContainer(container: HTMLElement): void {
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
.filter(el => this.isStackedEvent(el as HTMLElement)) as HTMLElement[];
if (stackedEvents.length === 0) return;
// Group events by their stack chains
const processedEventIds = new Set<string>();
const stackChains: HTMLElement[][] = [];
for (const element of stackedEvents) {
const eventId = element.dataset.eventId;
if (!eventId || processedEventIds.has(eventId)) continue;
// Find the root of this stack chain (stackLevel 0 or no prev link)
let rootElement = element;
let rootLink = this.getStackLink(rootElement);
while (rootLink?.prev) {
const prevElement = this.findElementById(rootLink.prev);
if (!prevElement) break;
rootElement = prevElement;
rootLink = this.getStackLink(rootElement);
}
// Collect all elements in this chain
const chain: HTMLElement[] = [];
let currentElement = rootElement;
while (currentElement) {
chain.push(currentElement);
processedEventIds.add(currentElement.dataset.eventId!);
const currentLink = this.getStackLink(currentElement);
if (!currentLink?.next) break;
const nextElement = this.findElementById(currentLink.next);
if (!nextElement) break;
currentElement = nextElement;
}
if (chain.length > 1) { // Only add chains with multiple events
stackChains.push(chain);
}
}
// Re-stack each chain separately
stackChains.forEach(chain => {
chain.forEach((element, index) => {
const marginLeft = index * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
element.style.marginLeft = `${marginLeft}px`;
element.style.zIndex = `${100 + index}`;
// Update the data-stack-link with correct stackLevel
const link = this.getStackLink(element);
if (link) {
this.setStackLink(element, {
...link,
stackLevel: index
});
}
});
});
}
/**
* Utility methods - simple DOM traversal
*/
public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
return eventElement.closest('swp-event-group') as HTMLElement;
}
public isInEventGroup(element: HTMLElement): boolean {
return this.getEventGroup(element) !== null;
}
/**
* Helper methods for data-attribute based stack tracking
*/
public getStackLink(element: HTMLElement): StackLink | null {
const linkData = element.dataset.stackLink;
if (!linkData) return null;
try {
return JSON.parse(linkData);
} catch (e) {
console.warn('Failed to parse stack link data:', linkData, e);
return null;
}
}
private setStackLink(element: HTMLElement, link: StackLink | null): void {
if (link === null) {
delete element.dataset.stackLink;
} else {
element.dataset.stackLink = JSON.stringify(link);
}
}
private findElementById(eventId: string): HTMLElement | null {
return document.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
}
}

View file

@ -1,167 +0,0 @@
/**
* MonthViewStrategy - Strategy for month view rendering
* Completely different from week view - no time axis, cell-based events
*/
import { ViewStrategy, ViewContext, ViewLayoutConfig } from './ViewStrategy';
import { DateService } from '../utils/DateService';
import { calendarConfig } from '../core/CalendarConfig';
import { CalendarEvent } from '../types/CalendarTypes';
export class MonthViewStrategy implements ViewStrategy {
private dateService: DateService;
constructor() {
this.dateService = new DateService('Europe/Copenhagen');
}
getLayoutConfig(): ViewLayoutConfig {
return {
needsTimeAxis: false, // No time axis in month view!
columnCount: 7, // Always 7 days (Mon-Sun)
scrollable: false, // Month fits in viewport
eventPositioning: 'cell-based' // Events go in day cells
};
}
renderGrid(context: ViewContext): void {
// Clear existing content
context.container.innerHTML = '';
// Create month grid (completely different from week!)
this.createMonthGrid(context);
}
private createMonthGrid(context: ViewContext): void {
const monthGrid = document.createElement('div');
monthGrid.className = 'month-grid';
monthGrid.style.display = 'grid';
monthGrid.style.gridTemplateColumns = 'repeat(7, 1fr)';
monthGrid.style.gridTemplateRows = 'auto repeat(6, 1fr)';
monthGrid.style.height = '100%';
// Add day headers (Mon, Tue, Wed, etc.)
this.createDayHeaders(monthGrid);
// Add 6 weeks of day cells
this.createDayCells(monthGrid, context.currentDate);
// Render events in day cells (will be handled by EventRendererManager)
// this.renderMonthEvents(monthGrid, context.allDayEvents);
context.container.appendChild(monthGrid);
}
private createDayHeaders(container: HTMLElement): void {
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
dayNames.forEach(dayName => {
const header = document.createElement('div');
header.className = 'month-day-header';
header.textContent = dayName;
header.style.padding = '8px';
header.style.fontWeight = 'bold';
header.style.textAlign = 'center';
header.style.borderBottom = '1px solid #e0e0e0';
container.appendChild(header);
});
}
private createDayCells(container: HTMLElement, monthDate: Date): void {
const dates = this.getMonthDates(monthDate);
dates.forEach(date => {
const cell = document.createElement('div');
cell.className = 'month-day-cell';
cell.dataset.date = this.dateService.formatISODate(date);
cell.style.border = '1px solid #e0e0e0';
cell.style.minHeight = '100px';
cell.style.padding = '4px';
cell.style.position = 'relative';
// Day number
const dayNumber = document.createElement('div');
dayNumber.className = 'month-day-number';
dayNumber.textContent = date.getDate().toString();
dayNumber.style.fontWeight = 'bold';
dayNumber.style.marginBottom = '4px';
// Check if today
if (this.dateService.isSameDay(date, new Date())) {
dayNumber.style.color = '#1976d2';
cell.style.backgroundColor = '#f5f5f5';
}
cell.appendChild(dayNumber);
container.appendChild(cell);
});
}
private getMonthDates(monthDate: Date): Date[] {
// Get first day of month using DateService
const year = monthDate.getFullYear();
const month = monthDate.getMonth();
const firstOfMonth = this.dateService.startOfDay(new Date(year, month, 1));
// Get Monday of the week containing first day
const weekBounds = this.dateService.getWeekBounds(firstOfMonth);
const startDate = this.dateService.startOfDay(weekBounds.start);
// Generate 42 days (6 weeks)
const dates: Date[] = [];
for (let i = 0; i < 42; i++) {
dates.push(this.dateService.addDays(startDate, i));
}
return dates;
}
private renderMonthEvents(container: HTMLElement, events: CalendarEvent[]): void {
// TODO: Implement month event rendering
// Events will be small blocks in day cells
}
getNextPeriod(currentDate: Date): Date {
const nextMonth = this.dateService.addMonths(currentDate, 1);
const year = nextMonth.getFullYear();
const month = nextMonth.getMonth();
return this.dateService.startOfDay(new Date(year, month, 1));
}
getPreviousPeriod(currentDate: Date): Date {
const prevMonth = this.dateService.addMonths(currentDate, -1);
const year = prevMonth.getFullYear();
const month = prevMonth.getMonth();
return this.dateService.startOfDay(new Date(year, month, 1));
}
getPeriodLabel(date: Date): string {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
return `${monthNames[date.getMonth()]} ${date.getFullYear()}`;
}
getDisplayDates(baseDate: Date): Date[] {
return this.getMonthDates(baseDate);
}
getPeriodRange(baseDate: Date): { startDate: Date; endDate: Date } {
// Month view shows events for the entire month grid (including partial weeks)
const year = baseDate.getFullYear();
const month = baseDate.getMonth();
const firstOfMonth = this.dateService.startOfDay(new Date(year, month, 1));
// Get Monday of the week containing first day
const weekBounds = this.dateService.getWeekBounds(firstOfMonth);
const startDate = this.dateService.startOfDay(weekBounds.start);
// End date is 41 days after start (42 total days)
const endDate = this.dateService.addDays(startDate, 41);
return {
startDate,
endDate
};
}
}

View file

@ -1,77 +0,0 @@
/**
* WeekViewStrategy - Strategy for week/day view rendering
* Extracts the time-based grid logic from GridManager
*/
import { ViewStrategy, ViewContext, ViewLayoutConfig } from './ViewStrategy';
import { DateService } from '../utils/DateService';
import { calendarConfig } from '../core/CalendarConfig';
import { GridRenderer } from '../renderers/GridRenderer';
import { GridStyleManager } from '../renderers/GridStyleManager';
export class WeekViewStrategy implements ViewStrategy {
private dateService: DateService;
private gridRenderer: GridRenderer;
private styleManager: GridStyleManager;
constructor() {
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
this.gridRenderer = new GridRenderer();
this.styleManager = new GridStyleManager();
}
getLayoutConfig(): ViewLayoutConfig {
return {
needsTimeAxis: true,
columnCount: calendarConfig.getWorkWeekSettings().totalDays,
scrollable: true,
eventPositioning: 'time-based'
};
}
renderGrid(context: ViewContext): void {
// Update grid styles
this.styleManager.updateGridStyles(context.resourceData);
// Render the grid structure (time axis + day columns)
this.gridRenderer.renderGrid(
context.container,
context.currentDate,
context.resourceData
);
}
getNextPeriod(currentDate: Date): Date {
return this.dateService.addWeeks(currentDate, 1);
}
getPreviousPeriod(currentDate: Date): Date {
return this.dateService.addWeeks(currentDate, -1);
}
getPeriodLabel(date: Date): string {
const weekBounds = this.dateService.getWeekBounds(date);
const weekStart = this.dateService.startOfDay(weekBounds.start);
const weekEnd = this.dateService.addDays(weekStart, 6);
const weekNumber = this.dateService.getWeekNumber(date);
return `Week ${weekNumber}: ${this.dateService.formatDateRange(weekStart, weekEnd)}`;
}
getDisplayDates(baseDate: Date): Date[] {
const workWeekSettings = calendarConfig.getWorkWeekSettings();
return this.dateService.getWorkWeekDates(baseDate, workWeekSettings.workDays);
}
getPeriodRange(baseDate: Date): { startDate: Date; endDate: Date } {
const weekBounds = this.dateService.getWeekBounds(baseDate);
const weekStart = this.dateService.startOfDay(weekBounds.start);
const weekEnd = this.dateService.addDays(weekStart, 6);
return {
startDate: weekStart,
endDate: weekEnd
};
}
}

View file

@ -1,176 +0,0 @@
import { CalendarEvent, CalendarView } from './CalendarTypes';
import {
DragStartEventPayload,
DragMoveEventPayload,
DragEndEventPayload,
DragMouseEnterHeaderEventPayload,
DragMouseLeaveHeaderEventPayload,
HeaderReadyEventPayload
} from './EventTypes';
import { CoreEvents } from '../constants/CoreEvents';
/**
* Complete type mapping for all calendar events
* This enables type-safe event emission and handling
*/
export interface CalendarEventPayloadMap {
// Lifecycle events
[CoreEvents.INITIALIZED]: {
initialized: boolean;
timestamp: number;
};
[CoreEvents.READY]: undefined;
[CoreEvents.DESTROYED]: undefined;
// View events
[CoreEvents.VIEW_CHANGED]: {
view: CalendarView;
previousView?: CalendarView;
};
[CoreEvents.VIEW_RENDERED]: {
view: CalendarView;
};
[CoreEvents.WORKWEEK_CHANGED]: {
settings: unknown;
};
// Navigation events
[CoreEvents.DATE_CHANGED]: {
date: Date;
view?: CalendarView;
};
[CoreEvents.NAVIGATION_COMPLETED]: {
direction: 'previous' | 'next' | 'today';
};
[CoreEvents.PERIOD_INFO_UPDATE]: {
label: string;
startDate: Date;
endDate: Date;
};
[CoreEvents.NAVIGATE_TO_EVENT]: {
eventId: string;
};
// Data events
[CoreEvents.DATA_LOADING]: undefined;
[CoreEvents.DATA_LOADED]: {
events: CalendarEvent[];
count: number;
};
[CoreEvents.DATA_ERROR]: {
error: Error;
};
[CoreEvents.EVENTS_FILTERED]: {
filteredEvents: CalendarEvent[];
};
// Grid events
[CoreEvents.GRID_RENDERED]: {
container: HTMLElement;
currentDate: Date;
startDate: Date;
endDate: Date;
columnCount: number;
};
[CoreEvents.GRID_CLICKED]: {
column: string;
row: number;
};
[CoreEvents.CELL_SELECTED]: {
cell: HTMLElement;
};
// Event management
[CoreEvents.EVENT_CREATED]: {
event: CalendarEvent;
};
[CoreEvents.EVENT_UPDATED]: {
event: CalendarEvent;
previousData?: Partial<CalendarEvent>;
};
[CoreEvents.EVENT_DELETED]: {
eventId: string;
};
[CoreEvents.EVENT_SELECTED]: {
eventId: string;
event?: CalendarEvent;
};
// System events
[CoreEvents.ERROR]: {
error: Error;
context?: string;
};
[CoreEvents.REFRESH_REQUESTED]: {
view?: CalendarView;
date?: Date;
};
// Filter events
[CoreEvents.FILTER_CHANGED]: {
activeFilters: string[];
visibleEvents: CalendarEvent[];
};
// Rendering events
[CoreEvents.EVENTS_RENDERED]: {
eventCount: number;
};
// Drag events
'drag:start': DragStartEventPayload;
'drag:move': DragMoveEventPayload;
'drag:end': DragEndEventPayload;
'drag:mouseenter-header': DragMouseEnterHeaderEventPayload;
'drag:mouseleave-header': DragMouseLeaveHeaderEventPayload;
'drag:cancelled': {
reason: string;
};
// Header events
'header:ready': HeaderReadyEventPayload;
'header:height-changed': {
height: number;
rowCount: number;
};
// All-day events
'allday:convert-to-allday': {
eventId: string;
element: HTMLElement;
};
'allday:convert-from-allday': {
eventId: string;
element: HTMLElement;
};
// Scroll events
'scroll:sync': {
scrollTop: number;
source: string;
};
'scroll:to-hour': {
hour: number;
};
// Filter events
'filter:updated': {
activeFilters: string[];
visibleEvents: CalendarEvent[];
};
'filter:search': {
query: string;
results: CalendarEvent[];
};
}
// Helper type to get payload type for a specific event
export type EventPayload<T extends keyof CalendarEventPayloadMap> = CalendarEventPayloadMap[T];
// Type guard to check if an event has a payload
export function hasPayload<T extends keyof CalendarEventPayloadMap>(
eventType: T,
payload: unknown
): payload is CalendarEventPayloadMap[T] {
return payload !== undefined;
}

View file

@ -1,5 +1,4 @@
import { IEventBus, CalendarEvent, CalendarView } from './CalendarTypes';
import { IManager } from '../interfaces/IManager';
/**
* Complete type definition for all managers returned by ManagerFactory
@ -16,6 +15,14 @@ export interface CalendarManagers {
allDayManager: unknown; // Avoid interface conflicts
}
/**
* Base interface for managers with optional initialization and refresh
*/
interface IManager {
initialize?(): Promise<void> | void;
refresh?(): void;
}
export interface EventManager extends IManager {
loadData(): Promise<void>;
getEvents(): CalendarEvent[];

View file

@ -1,75 +0,0 @@
/**
* OverlapDetector - Ren tidbaseret overlap detection
* Ingen DOM manipulation, kun tidsberegninger
*/
import { CalendarEvent } from '../types/CalendarTypes';
// Branded type for event IDs
export type EventId = string & { readonly __brand: 'EventId' };
export type OverlapResult = {
overlappingEvents: CalendarEvent[];
stackLinks: Map<EventId, StackLink>;
};
export interface StackLink {
prev?: EventId; // Event ID of previous event in stack
next?: EventId; // Event ID of next event in stack
stackLevel: number; // 0 = base event, 1 = first stacked, etc
}
export class OverlapDetector {
/**
* Resolver hvilke events et givent event overlapper med i en kolonne
* @param event - CalendarEvent der skal checkes for overlap
* @param columnEvents - Array af CalendarEvent objekter i kolonnen
* @returns Array af events som det givne event overlapper med
*/
public resolveOverlap(event: CalendarEvent, columnEvents: CalendarEvent[]): CalendarEvent[] {
return columnEvents.filter(existingEvent => {
// To events overlapper hvis:
// event starter før existing slutter OG
// event slutter efter existing starter
return event.start < existingEvent.end && event.end > existingEvent.start;
});
}
/**
* Dekorerer events med stack linking data
* @param newEvent - Det nye event der skal tilføjes
* @param overlappingEvents - Events som det nye event overlapper med
* @returns OverlapResult med overlappende events og stack links
*/
public decorateWithStackLinks(newEvent: CalendarEvent, overlappingEvents: CalendarEvent[]): OverlapResult {
const stackLinks = new Map<EventId, StackLink>();
if (overlappingEvents.length === 0) {
return {
overlappingEvents: [],
stackLinks
};
}
// Kombiner nyt event med eksisterende og sortér efter start tid (tidligste første)
const allEvents = [...overlappingEvents, newEvent].sort((a, b) =>
a.start.getTime() - b.start.getTime()
);
// Opret sammenhængende kæde - alle events bindes sammen
allEvents.forEach((event, index) => {
const stackLink: StackLink = {
stackLevel: index,
prev: index > 0 ? allEvents[index - 1].id as EventId : undefined,
next: index < allEvents.length - 1 ? allEvents[index + 1].id as EventId : undefined
};
stackLinks.set(event.id as EventId, stackLink);
});
overlappingEvents.push(newEvent);
return {
overlappingEvents,
stackLinks
};
}
}