Refactors calendar project structure and build configuration

Consolidates V2 codebase into main project directory
Updates build script to support simplified entry points
Removes redundant files and cleans up project organization

Simplifies module imports and entry points for calendar application
This commit is contained in:
Janus C. H. Knudsen 2025-12-17 23:54:25 +01:00
parent 9f360237cf
commit 863b433eba
200 changed files with 2331 additions and 16193 deletions

View file

@ -1,744 +0,0 @@
// All-day row height management and animations
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine';
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { IColumnDataSource } from '../types/ColumnDataSource';
import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
import {
IDragMouseEnterHeaderEventPayload,
IDragMouseEnterColumnEventPayload,
IDragStartEventPayload,
IDragMoveEventPayload,
IDragEndEventPayload,
IDragColumnChangeEventPayload,
IHeaderReadyEventPayload
} from '../types/EventTypes';
import { IDragOffset, IMousePosition } from '../types/DragDropTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { EventManager } from './EventManager';
import { DateService } from '../utils/DateService';
import { EventId } from '../types/EventId';
/**
* AllDayManager - Handles all-day row height animations and management
* Uses AllDayLayoutEngine for all overlap detection and layout calculation
*/
export class AllDayManager {
private allDayEventRenderer: AllDayEventRenderer;
private eventManager: EventManager;
private dateService: DateService;
private dataSource: IColumnDataSource;
private layoutEngine: AllDayLayoutEngine | null = null;
// State tracking for layout calculation
private currentAllDayEvents: ICalendarEvent[] = [];
private currentColumns: IColumnBounds[] = [];
// Expand/collapse state
private isExpanded: boolean = false;
private actualRowCount: number = 0;
constructor(
eventManager: EventManager,
allDayEventRenderer: AllDayEventRenderer,
dateService: DateService,
dataSource: IColumnDataSource
) {
this.eventManager = eventManager;
this.allDayEventRenderer = allDayEventRenderer;
this.dateService = dateService;
this.dataSource = dataSource;
// Sync CSS variable with TypeScript constant to ensure consistency
document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`);
this.setupEventListeners();
}
/**
* Setup event listeners for drag conversions
*/
private setupEventListeners(): void {
eventBus.on('drag:mouseenter-header', (event) => {
const payload = (event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
if (payload.draggedClone.hasAttribute('data-allday'))
return;
console.log('🔄 AllDayManager: Received drag:mouseenter-header', {
targetDate: payload.targetColumn,
originalElementId: payload.originalElement?.dataset?.eventId,
originalElementTag: payload.originalElement?.tagName
});
this.handleConvertToAllDay(payload);
});
eventBus.on('drag:mouseleave-header', (event) => {
const { originalElement, cloneElement } = (event as CustomEvent).detail;
console.log('🚪 AllDayManager: Received drag:mouseleave-header', {
originalElementId: originalElement?.dataset?.eventId
});
});
// Listen for drag operations on all-day events
eventBus.on('drag:start', (event) => {
let payload: IDragStartEventPayload = (event as CustomEvent<IDragStartEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.allDayEventRenderer.handleDragStart(payload);
});
eventBus.on('drag:column-change', (event) => {
let payload: IDragColumnChangeEventPayload = (event as CustomEvent<IDragColumnChangeEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.handleColumnChange(payload);
});
eventBus.on('drag:end', (event) => {
let dragEndPayload: IDragEndEventPayload = (event as CustomEvent<IDragEndEventPayload>).detail;
console.log('🎯 AllDayManager: drag:end received', {
target: dragEndPayload.target,
originalElementTag: dragEndPayload.originalElement?.tagName,
hasAllDayAttribute: dragEndPayload.originalElement?.hasAttribute('data-allday'),
eventId: dragEndPayload.originalElement?.dataset.eventId
});
// Handle all-day → all-day drops (within header)
if (dragEndPayload.target === 'swp-day-header' && dragEndPayload.originalElement?.hasAttribute('data-allday')) {
console.log('✅ AllDayManager: Handling all-day → all-day drop');
this.handleDragEnd(dragEndPayload);
return;
}
// Handle timed → all-day conversion (dropped in header)
if (dragEndPayload.target === 'swp-day-header' && !dragEndPayload.originalElement?.hasAttribute('data-allday')) {
console.log('🔄 AllDayManager: Timed → all-day conversion on drop');
this.handleTimedToAllDayDrop(dragEndPayload);
return;
}
// Handle all-day → timed conversion (dropped in column)
if (dragEndPayload.target === 'swp-day-column' && dragEndPayload.originalElement?.hasAttribute('data-allday')) {
const eventId = dragEndPayload.originalElement.dataset.eventId;
console.log('🔄 AllDayManager: All-day → timed conversion', { eventId });
// Mark for removal (sets data-removing attribute)
this.fadeOutAndRemove(dragEndPayload.originalElement);
// Recalculate layout WITHOUT the removed event to compress gaps
const remainingEvents = this.currentAllDayEvents.filter(e => e.id !== eventId);
const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentColumns);
// Re-render all-day events with compressed layout
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// NOW animate height with compressed layout
this.checkAndAnimateAllDayHeight();
}
});
// Listen for drag cancellation to recalculate height
eventBus.on('drag:cancelled', (event) => {
const { draggedElement, reason } = (event as CustomEvent).detail;
console.log('🚫 AllDayManager: Drag cancelled', {
eventId: draggedElement?.dataset?.eventId,
reason
});
});
// Listen for header ready - when dates are populated with period data
eventBus.on('header:ready', async (event: Event) => {
let headerReadyEventPayload = (event as CustomEvent<IHeaderReadyEventPayload>).detail;
let startDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(0)!.identifier);
let endDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(-1)!.identifier);
let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate);
// Filter for all-day events
const allDayEvents = events.filter(event => event.allDay);
const layouts = this.calculateAllDayEventsLayout(allDayEvents, headerReadyEventPayload.headerElements);
this.allDayEventRenderer.renderAllDayEventsForPeriod(layouts);
this.checkAndAnimateAllDayHeight();
});
eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => {
this.allDayEventRenderer.handleViewChanged(event as CustomEvent);
});
}
private getAllDayContainer(): HTMLElement | null {
return document.querySelector('swp-calendar-header swp-allday-container');
}
private getCalendarHeader(): HTMLElement | null {
return document.querySelector('swp-calendar-header');
}
private getHeaderSpacer(): HTMLElement | null {
return document.querySelector('swp-header-spacer');
}
/**
* Read current max row from DOM elements
* Excludes events marked as removing (data-removing attribute)
*/
private getMaxRowFromDOM(): number {
const container = this.getAllDayContainer();
if (!container) return 0;
let maxRow = 0;
const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator):not([data-removing])');
allDayEvents.forEach((element: Element) => {
const htmlElement = element as HTMLElement;
const row = parseInt(htmlElement.style.gridRow) || 1;
maxRow = Math.max(maxRow, row);
});
return maxRow;
}
/**
* Get current gridArea for an event from DOM
*/
private getGridAreaFromDOM(eventId: string): string | null {
const container = this.getAllDayContainer();
if (!container) return null;
const element = container.querySelector(`[data-event-id="${eventId}"]`) as HTMLElement;
return element?.style.gridArea || null;
}
/**
* Count events in a specific column by reading DOM
*/
private countEventsInColumnFromDOM(columnIndex: number): number {
const container = this.getAllDayContainer();
if (!container) return 0;
let count = 0;
const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator)');
allDayEvents.forEach((element: Element) => {
const htmlElement = element as HTMLElement;
const gridColumn = htmlElement.style.gridColumn;
// Parse "1 / 3" format
const match = gridColumn.match(/(\d+)\s*\/\s*(\d+)/);
if (match) {
const startCol = parseInt(match[1]);
const endCol = parseInt(match[2]) - 1; // End is exclusive in CSS
if (startCol <= columnIndex && endCol >= columnIndex) {
count++;
}
}
});
return count;
}
/**
* Calculate all-day height based on number of rows
*/
private calculateAllDayHeight(targetRows: number): {
targetHeight: number;
currentHeight: number;
heightDifference: number;
} {
const root = document.documentElement;
const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT;
// Read CSS variable directly from style property or default to 0
const currentHeightStr = root.style.getPropertyValue('--all-day-row-height') || '0px';
const currentHeight = parseInt(currentHeightStr) || 0;
const heightDifference = targetHeight - currentHeight;
return { targetHeight, currentHeight, heightDifference };
}
/**
* Check current all-day events and animate to correct height
* Reads max row directly from DOM elements
*/
public checkAndAnimateAllDayHeight(): void {
// Read max row directly from DOM
const maxRows = this.getMaxRowFromDOM();
console.log('📊 AllDayManager: Height calculation', {
maxRows,
isExpanded: this.isExpanded
});
// Store actual row count
this.actualRowCount = maxRows;
// Determine what to display
let displayRows = maxRows;
if (maxRows > ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS) {
// Show chevron button
this.updateChevronButton(true);
// Show 4 rows when collapsed (3 events + indicators)
if (!this.isExpanded) {
displayRows = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS;
this.updateOverflowIndicators();
} else {
this.clearOverflowIndicators();
}
} else {
// Hide chevron - not needed
this.updateChevronButton(false);
this.clearOverflowIndicators();
}
console.log('🎬 AllDayManager: Will animate to', {
displayRows,
maxRows,
willAnimate: displayRows !== this.actualRowCount
});
console.log(`🎯 AllDayManager: Animating to ${displayRows} rows`);
// Animate to required rows (0 = collapse, >0 = expand)
this.animateToRows(displayRows);
}
/**
* Animate all-day container to specific number of rows
*/
public animateToRows(targetRows: number): void {
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
if (targetHeight === currentHeight) return; // No animation needed
console.log(`🎬 All-day height animation: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)}${targetRows} rows)`);
// Get cached elements
const calendarHeader = this.getCalendarHeader();
const headerSpacer = this.getHeaderSpacer();
const allDayContainer = this.getAllDayContainer();
if (!calendarHeader || !allDayContainer) return;
// Get current parent height for animation
const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height);
const targetParentHeight = currentParentHeight + heightDifference;
const animations = [
calendarHeader.animate([
{ height: `${currentParentHeight}px` },
{ height: `${targetParentHeight}px` }
], {
duration: 150,
easing: 'ease-out',
fill: 'forwards'
})
];
// Add spacer animation if spacer exists, but don't use fill: 'forwards'
if (headerSpacer) {
const root = document.documentElement;
const headerHeightStr = root.style.getPropertyValue('--header-height');
const headerHeight = parseInt(headerHeightStr);
const currentSpacerHeight = headerHeight + currentHeight;
const targetSpacerHeight = headerHeight + targetHeight;
animations.push(
headerSpacer.animate([
{ height: `${currentSpacerHeight}px` },
{ height: `${targetSpacerHeight}px` }
], {
duration: 150,
easing: 'ease-out'
// No fill: 'forwards' - let CSS calc() take over after animation
})
);
}
// Update CSS variable after animation
Promise.all(animations.map(anim => anim.finished)).then(() => {
const root = document.documentElement;
root.style.setProperty('--all-day-row-height', `${targetHeight}px`);
eventBus.emit('header:height-changed');
});
}
/**
* Calculate layout for ALL all-day events using AllDayLayoutEngine
* This is the correct method that processes all events together for proper overlap detection
*/
private calculateAllDayEventsLayout(events: ICalendarEvent[], dayHeaders: IColumnBounds[]): IEventLayout[] {
// Store current state
this.currentAllDayEvents = events;
this.currentColumns = dayHeaders;
// Map IColumnBounds to IColumnInfo structure (identifier + groupId)
const columns = dayHeaders.map(column => ({
identifier: column.identifier,
groupId: column.element.dataset.groupId || column.identifier,
data: new Date(), // Not used by AllDayLayoutEngine
events: [] // Not used by AllDayLayoutEngine
}));
// Initialize layout engine with column info including groupId
let layoutEngine = new AllDayLayoutEngine(columns);
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
return layoutEngine.calculateLayout(events);
}
private handleConvertToAllDay(payload: IDragMouseEnterHeaderEventPayload): void {
let allDayContainer = this.getAllDayContainer();
if (!allDayContainer) return;
// Create SwpAllDayEventElement from ICalendarEvent
const allDayElement = SwpAllDayEventElement.fromCalendarEvent(payload.calendarEvent);
// Apply grid positioning
allDayElement.style.gridRow = '1';
allDayElement.style.gridColumn = payload.targetColumn.index.toString();
// Remove old swp-event clone
payload.draggedClone.remove();
// Call delegate to update DragDropManager's draggedClone reference
payload.replaceClone(allDayElement);
// Append to container
allDayContainer.appendChild(allDayElement);
ColumnDetectionUtils.updateColumnBoundsCache();
// Recalculate height after adding all-day event
this.checkAndAnimateAllDayHeight();
}
/**
* Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER
*/
private handleColumnChange(dragColumnChangeEventPayload: IDragColumnChangeEventPayload): void {
let allDayContainer = this.getAllDayContainer();
if (!allDayContainer) return;
let targetColumn = ColumnDetectionUtils.getColumnBounds(dragColumnChangeEventPayload.mousePosition);
if (targetColumn == null)
return;
if (!dragColumnChangeEventPayload.draggedClone)
return;
// Calculate event span from original grid positioning
const computedStyle = window.getComputedStyle(dragColumnChangeEventPayload.draggedClone);
const gridColumnStart = parseInt(computedStyle.gridColumnStart) || targetColumn.index;
const gridColumnEnd = parseInt(computedStyle.gridColumnEnd) || targetColumn.index + 1;
const span = gridColumnEnd - gridColumnStart;
// Update clone position maintaining the span
const newStartColumn = targetColumn.index;
const newEndColumn = newStartColumn + span;
dragColumnChangeEventPayload.draggedClone.style.gridColumn = `${newStartColumn} / ${newEndColumn}`;
}
private fadeOutAndRemove(element: HTMLElement): void {
console.log('🗑️ AllDayManager: About to remove all-day event', {
eventId: element.dataset.eventId,
element: element.tagName
});
// Mark element as removing so it's excluded from height calculations
element.setAttribute('data-removing', 'true');
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
setTimeout(() => {
element.remove();
console.log('✅ AllDayManager: All-day event removed from DOM');
}, 300);
}
/**
* Handle timed all-day conversion on drop
*/
private async handleTimedToAllDayDrop(dragEndEvent: IDragEndEventPayload): Promise<void> {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = EventId.from(clone.eventId);
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
// Determine target date based on mode
let targetDate: Date;
let resourceId: string | undefined;
if (this.dataSource.isResource()) {
// Resource mode: keep event's existing date, set resourceId
targetDate = clone.start;
resourceId = columnIdentifier;
} else {
// Date mode: parse date from column identifier
targetDate = this.dateService.parseISO(columnIdentifier);
}
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate, resourceId });
// Create new dates preserving time
const newStart = new Date(targetDate);
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
const newEnd = new Date(targetDate);
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
// Build update payload
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
start: newStart,
end: newEnd,
allDay: true
};
if (resourceId) {
updatePayload.resourceId = resourceId;
}
// Update event in repository
await this.eventManager.updateEvent(eventId, updatePayload);
// Remove original timed event
this.fadeOutAndRemove(dragEndEvent.originalElement);
// Add to current all-day events and recalculate layout
const newEvent: ICalendarEvent = {
id: eventId,
title: clone.title,
start: newStart,
end: newEnd,
type: clone.type as CalendarEventType,
allDay: true,
syncStatus: 'synced'
};
const updatedEvents = [...this.currentAllDayEvents, newEvent];
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height
this.checkAndAnimateAllDayHeight();
}
/**
* Handle all-day all-day drop (moving within header)
*/
private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise<void> {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = EventId.from(clone.eventId);
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
// Determine target date based on mode
let targetDate: Date;
let resourceId: string | undefined;
if (this.dataSource.isResource()) {
// Resource mode: keep event's existing date, set resourceId
targetDate = clone.start;
resourceId = columnIdentifier;
} else {
// Date mode: parse date from column identifier
targetDate = this.dateService.parseISO(columnIdentifier);
}
// Calculate duration in days
const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start);
// Create new dates preserving time
const newStart = new Date(targetDate);
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
const newEnd = new Date(targetDate);
newEnd.setDate(newEnd.getDate() + durationDays);
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
// Build update payload
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
start: newStart,
end: newEnd,
allDay: true
};
if (resourceId) {
updatePayload.resourceId = resourceId;
}
// Update event in repository
await this.eventManager.updateEvent(eventId, updatePayload);
// Remove original and fade out
this.fadeOutAndRemove(dragEndEvent.originalElement);
// Recalculate and re-render ALL events
const updatedEvents = this.currentAllDayEvents.map(e =>
e.id === eventId ? { ...e, start: newStart, end: newEnd } : e
);
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height - this also handles overflow classes!
this.checkAndAnimateAllDayHeight();
}
/**
* Update chevron button visibility and state
*/
private updateChevronButton(show: boolean): void {
const headerSpacer = this.getHeaderSpacer();
if (!headerSpacer) return;
let chevron = headerSpacer.querySelector('.allday-chevron') as HTMLElement;
if (show && !chevron) {
chevron = document.createElement('button');
chevron.className = 'allday-chevron collapsed';
chevron.innerHTML = `
<svg width="12" height="8" viewBox="0 0 12 8">
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
`;
chevron.onclick = () => this.toggleExpanded();
headerSpacer.appendChild(chevron);
} else if (!show && chevron) {
chevron.remove();
} else if (chevron) {
chevron.classList.toggle('collapsed', !this.isExpanded);
chevron.classList.toggle('expanded', this.isExpanded);
}
}
/**
* Toggle between expanded and collapsed state
*/
private toggleExpanded(): void {
this.isExpanded = !this.isExpanded;
this.checkAndAnimateAllDayHeight();
const elements = document.querySelectorAll('swp-allday-container swp-allday-event.max-event-overflow-hide, swp-allday-container swp-allday-event.max-event-overflow-show');
elements.forEach((element) => {
if (this.isExpanded) {
// ALTID vis når expanded=true
element.classList.remove('max-event-overflow-hide');
element.classList.add('max-event-overflow-show');
} else {
// ALTID skjul når expanded=false
element.classList.remove('max-event-overflow-show');
element.classList.add('max-event-overflow-hide');
}
});
}
/**
* Count number of events in a specific column using IColumnBounds
* Reads directly from DOM elements
*/
private countEventsInColumn(columnBounds: IColumnBounds): number {
return this.countEventsInColumnFromDOM(columnBounds.index);
}
/**
* Update overflow indicators for collapsed state
*/
private updateOverflowIndicators(): void {
const container = this.getAllDayContainer();
if (!container) return;
// Create overflow indicators for each column that needs them
let columns = ColumnDetectionUtils.getColumns();
columns.forEach((columnBounds) => {
let totalEventsInColumn = this.countEventsInColumn(columnBounds);
let overflowCount = totalEventsInColumn - ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS
if (overflowCount > 0) {
// Check if indicator already exists in this column
let existingIndicator = container.querySelector(`.max-event-indicator[data-column="${columnBounds.index}"]`) as HTMLElement;
if (existingIndicator) {
// Update existing indicator
existingIndicator.innerHTML = `<span>+${overflowCount + 1} more</span>`;
} else {
// Create new overflow indicator element
let overflowElement = document.createElement('swp-allday-event');
overflowElement.className = 'max-event-indicator';
overflowElement.setAttribute('data-column', columnBounds.index.toString());
overflowElement.style.gridRow = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS.toString();
overflowElement.style.gridColumn = columnBounds.index.toString();
overflowElement.innerHTML = `<span>+${overflowCount + 1} more</span>`;
overflowElement.onclick = (e) => {
e.stopPropagation();
this.toggleExpanded();
};
container.appendChild(overflowElement);
}
}
});
}
/**
* Clear overflow indicators and restore normal state
*/
private clearOverflowIndicators(): void {
const container = this.getAllDayContainer();
if (!container) return;
// Remove all overflow indicator elements
container.querySelectorAll('.max-event-indicator').forEach((element) => {
element.remove();
});
}
}

View file

@ -1,195 +0,0 @@
import { CoreEvents } from '../constants/CoreEvents';
import { Configuration } from '../configurations/CalendarConfig';
import { CalendarView, IEventBus } from '../types/CalendarTypes';
import { EventManager } from './EventManager';
import { GridManager } from './GridManager';
import { EventRenderingService } from '../renderers/EventRendererManager';
import { ScrollManager } from './ScrollManager';
/**
* CalendarManager - Main coordinator for all calendar managers
*/
export class CalendarManager {
private eventBus: IEventBus;
private eventManager: EventManager;
private gridManager: GridManager;
private eventRenderer: EventRenderingService;
private scrollManager: ScrollManager;
private config: Configuration;
private currentView: CalendarView;
private currentDate: Date = new Date();
private isInitialized: boolean = false;
constructor(
eventBus: IEventBus,
eventManager: EventManager,
gridManager: GridManager,
eventRenderingService: EventRenderingService,
scrollManager: ScrollManager,
config: Configuration
) {
this.eventBus = eventBus;
this.eventManager = eventManager;
this.gridManager = gridManager;
this.eventRenderer = eventRenderingService;
this.scrollManager = scrollManager;
this.config = config;
this.currentView = this.config.currentView;
this.setupEventListeners();
}
/**
* Initialize calendar system using simple direct calls
*/
public async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
try {
// Step 1: Load data
await this.eventManager.loadData();
// Step 2: Render grid structure
await this.gridManager.render();
this.scrollManager.initialize();
this.setView(this.currentView);
this.setCurrentDate(this.currentDate);
this.isInitialized = true;
// Emit initialization complete event
this.eventBus.emit(CoreEvents.INITIALIZED, {
currentDate: this.currentDate,
currentView: this.currentView
});
} catch (error) {
throw error;
}
}
/**
* Skift calendar view (dag/uge/måned)
*/
public setView(view: CalendarView): void {
if (this.currentView === view) {
return;
}
const previousView = this.currentView;
this.currentView = view;
// Emit view change event
this.eventBus.emit(CoreEvents.VIEW_CHANGED, {
previousView,
currentView: view,
date: this.currentDate
});
}
/**
* Sæt aktuel dato
*/
public setCurrentDate(date: Date): void {
const previousDate = this.currentDate;
this.currentDate = new Date(date);
// Emit date change event
this.eventBus.emit(CoreEvents.DATE_CHANGED, {
previousDate,
currentDate: this.currentDate,
view: this.currentView
});
}
/**
* Setup event listeners for at håndtere events fra andre managers
*/
private setupEventListeners(): void {
// Listen for workweek changes only
this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => {
const customEvent = event as CustomEvent;
this.handleWorkweekChange();
});
}
/**
* Calculate the current period based on view and date
*/
private calculateCurrentPeriod(): { start: string; end: string } {
const current = new Date(this.currentDate);
switch (this.currentView) {
case 'day':
const dayStart = new Date(current);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(current);
dayEnd.setHours(23, 59, 59, 999);
return {
start: dayStart.toISOString(),
end: dayEnd.toISOString()
};
case 'week':
// Find start of week (Monday)
const weekStart = new Date(current);
const dayOfWeek = weekStart.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Sunday = 0, so 6 days back to Monday
weekStart.setDate(weekStart.getDate() - daysToMonday);
weekStart.setHours(0, 0, 0, 0);
// Find end of week (Sunday)
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
return {
start: weekStart.toISOString(),
end: weekEnd.toISOString()
};
case 'month':
const monthStart = new Date(current.getFullYear(), current.getMonth(), 1);
const monthEnd = new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59, 999);
return {
start: monthStart.toISOString(),
end: monthEnd.toISOString()
};
default:
// Fallback to week view
const fallbackStart = new Date(current);
fallbackStart.setDate(fallbackStart.getDate() - 3);
fallbackStart.setHours(0, 0, 0, 0);
const fallbackEnd = new Date(current);
fallbackEnd.setDate(fallbackEnd.getDate() + 3);
fallbackEnd.setHours(23, 59, 59, 999);
return {
start: fallbackStart.toISOString(),
end: fallbackEnd.toISOString()
};
}
}
/**
* Handle workweek configuration changes
*/
private handleWorkweekChange(): void {
// Simply relay the event - workweek info is in the WORKWEEK_CHANGED event
this.eventBus.emit('workweek:header-update', {
currentDate: this.currentDate,
currentView: this.currentView
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,220 +1,140 @@
/**
* EdgeScrollManager - Auto-scroll when dragging near edges
* Uses time-based scrolling with 2-zone system for variable speed
*/
import { IEventBus } from '../types/CalendarTypes';
import { IDragMoveEventPayload, IDragStartEventPayload } from '../types/EventTypes';
export class EdgeScrollManager {
private scrollableContent: HTMLElement | null = null;
private timeGrid: HTMLElement | null = null;
private draggedClone: HTMLElement | null = null;
private scrollRAF: number | null = null;
private mouseY = 0;
private isDragging = false;
private isScrolling = false; // Track if edge-scroll is active
private lastTs = 0;
private rect: DOMRect | null = null;
private initialScrollTop = 0;
private scrollListener: ((e: Event) => void) | null = null;
// Constants - fixed values as per requirements
private readonly OUTER_ZONE = 100; // px from edge (slow zone)
private readonly INNER_ZONE = 50; // px from edge (fast zone)
private readonly SLOW_SPEED_PXS = 140; // px/sec in outer zone
private readonly FAST_SPEED_PXS = 640; // px/sec in inner zone
constructor(private eventBus: IEventBus) {
this.init();
}
private init(): void {
// Wait for DOM to be ready
setTimeout(() => {
this.scrollableContent = document.querySelector('swp-scrollable-content');
this.timeGrid = document.querySelector('swp-time-grid');
if (this.scrollableContent) {
// Disable smooth scroll for instant auto-scroll
this.scrollableContent.style.scrollBehavior = 'auto';
// Add scroll listener to detect actual scrolling
this.scrollListener = this.handleScroll.bind(this);
this.scrollableContent.addEventListener('scroll', this.scrollListener, { passive: true });
}
}, 100);
// Listen to mousemove directly from document to always get mouse coords
document.body.addEventListener('mousemove', (e: MouseEvent) => {
if (this.isDragging) {
this.mouseY = e.clientY;
}
});
this.subscribeToEvents();
}
private subscribeToEvents(): void {
// Listen to drag events from DragDropManager
this.eventBus.on('drag:start', (event: Event) => {
const payload = (event as CustomEvent).detail;
this.draggedClone = payload.draggedClone;
this.startDrag();
});
this.eventBus.on('drag:end', () => this.stopDrag());
this.eventBus.on('drag:cancelled', () => this.stopDrag());
// Stop scrolling when event converts to/from all-day
this.eventBus.on('drag:mouseenter-header', () => {
console.log('🔄 EdgeScrollManager: Event converting to all-day - stopping scroll');
this.stopDrag();
});
this.eventBus.on('drag:mouseenter-column', () => {
this.startDrag();
});
}
private startDrag(): void {
console.log('🎬 EdgeScrollManager: Starting drag');
this.isDragging = true;
this.isScrolling = false; // Reset scroll state
this.lastTs = performance.now();
// Save initial scroll position
if (this.scrollableContent) {
this.initialScrollTop = this.scrollableContent.scrollTop;
}
if (this.scrollRAF === null) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
}
}
private stopDrag(): void {
this.isDragging = false;
// Emit stopped event if we were scrolling
if (this.isScrolling) {
this.isScrolling = false;
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (drag ended)');
this.eventBus.emit('edgescroll:stopped', {});
}
if (this.scrollRAF !== null) {
cancelAnimationFrame(this.scrollRAF);
this.scrollRAF = null;
}
this.rect = null;
this.lastTs = 0;
this.initialScrollTop = 0;
}
private handleScroll(): void {
if (!this.isDragging || !this.scrollableContent) return;
const currentScrollTop = this.scrollableContent.scrollTop;
const scrollDelta = Math.abs(currentScrollTop - this.initialScrollTop);
// Only emit started event if we've actually scrolled more than 1px
if (scrollDelta > 1 && !this.isScrolling) {
this.isScrolling = true;
console.log('💾 EdgeScrollManager: Edge-scroll started (actual scroll detected)', {
initialScrollTop: this.initialScrollTop,
currentScrollTop,
scrollDelta
});
this.eventBus.emit('edgescroll:started', {});
}
}
private scrollTick(ts: number): void {
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
this.lastTs = ts;
if (!this.scrollableContent) {
this.stopDrag();
return;
}
// Cache rect for performance (only measure once per frame)
if (!this.rect) {
this.rect = this.scrollableContent.getBoundingClientRect();
}
let vy = 0;
if (this.isDragging) {
const distTop = this.mouseY - this.rect.top;
const distBot = this.rect.bottom - this.mouseY;
// Check top edge
if (distTop < this.INNER_ZONE) {
vy = -this.FAST_SPEED_PXS;
} else if (distTop < this.OUTER_ZONE) {
vy = -this.SLOW_SPEED_PXS;
}
// Check bottom edge
else if (distBot < this.INNER_ZONE) {
vy = this.FAST_SPEED_PXS;
} else if (distBot < this.OUTER_ZONE) {
vy = this.SLOW_SPEED_PXS;
}
}
if (vy !== 0 && this.isDragging && this.timeGrid && this.draggedClone) {
// Check if we can scroll in the requested direction
const currentScrollTop = this.scrollableContent.scrollTop;
const scrollableHeight = this.scrollableContent.clientHeight;
const timeGridHeight = this.timeGrid.clientHeight;
// Get dragged element position and height
const cloneRect = this.draggedClone.getBoundingClientRect();
const cloneBottom = cloneRect.bottom;
const timeGridRect = this.timeGrid.getBoundingClientRect();
const timeGridBottom = timeGridRect.bottom;
// Check boundaries
const atTop = currentScrollTop <= 0 && vy < 0;
const atBottom = (cloneBottom >= timeGridBottom) && vy > 0;
if (atTop || atBottom) {
// At boundary - stop scrolling
if (this.isScrolling) {
this.isScrolling = false;
this.initialScrollTop = this.scrollableContent.scrollTop;
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (reached boundary)');
this.eventBus.emit('edgescroll:stopped', {});
}
// Continue RAF loop to detect when mouse moves away from boundary
if (this.isDragging) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
}
} else {
// Not at boundary - apply scroll
this.scrollableContent.scrollTop += vy * dt;
this.rect = null; // Invalidate cache for next frame
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
}
} else {
// Mouse moved away from edge - stop scrolling
if (this.isScrolling) {
this.isScrolling = false;
this.initialScrollTop = this.scrollableContent.scrollTop; // Reset for next scroll
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (mouse left edge)');
this.eventBus.emit('edgescroll:stopped', {});
}
// Continue RAF loop even if not scrolling, to detect edge entry
if (this.isDragging) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
} else {
this.stopDrag();
}
}
}
}
/**
* EdgeScrollManager - Auto-scroll when dragging near viewport edges
*
* 2-zone system:
* - Inner zone (0-50px): Fast scroll (640 px/sec)
* - Outer zone (50-100px): Slow scroll (140 px/sec)
*/
import { IEventBus } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
export class EdgeScrollManager {
private scrollableContent: HTMLElement | null = null;
private timeGrid: HTMLElement | null = null;
private draggedElement: HTMLElement | null = null;
private scrollRAF: number | null = null;
private mouseY = 0;
private isDragging = false;
private isScrolling = false;
private lastTs = 0;
private rect: DOMRect | null = null;
private initialScrollTop = 0;
private readonly OUTER_ZONE = 100;
private readonly INNER_ZONE = 50;
private readonly SLOW_SPEED = 140;
private readonly FAST_SPEED = 640;
constructor(private eventBus: IEventBus) {
this.subscribeToEvents();
document.addEventListener('pointermove', this.trackMouse);
}
init(scrollableContent: HTMLElement): void {
this.scrollableContent = scrollableContent;
this.timeGrid = scrollableContent.querySelector('swp-time-grid');
this.scrollableContent.style.scrollBehavior = 'auto';
}
private trackMouse = (e: PointerEvent): void => {
if (this.isDragging) {
this.mouseY = e.clientY;
}
};
private subscribeToEvents(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
const payload = (event as CustomEvent).detail;
this.draggedElement = payload.element;
this.startDrag();
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => this.stopDrag());
}
private startDrag(): void {
this.isDragging = true;
this.isScrolling = false;
this.lastTs = 0;
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
if (this.scrollRAF === null) {
this.scrollRAF = requestAnimationFrame(this.scrollTick);
}
}
private stopDrag(): void {
this.isDragging = false;
this.setScrollingState(false);
if (this.scrollRAF !== null) {
cancelAnimationFrame(this.scrollRAF);
this.scrollRAF = null;
}
this.rect = null;
this.lastTs = 0;
this.initialScrollTop = 0;
}
private calculateVelocity(): number {
if (!this.rect) return 0;
const distTop = this.mouseY - this.rect.top;
const distBot = this.rect.bottom - this.mouseY;
if (distTop < this.INNER_ZONE) return -this.FAST_SPEED;
if (distTop < this.OUTER_ZONE) return -this.SLOW_SPEED;
if (distBot < this.INNER_ZONE) return this.FAST_SPEED;
if (distBot < this.OUTER_ZONE) return this.SLOW_SPEED;
return 0;
}
private isAtBoundary(velocity: number): boolean {
if (!this.scrollableContent || !this.timeGrid || !this.draggedElement) return false;
const atTop = this.scrollableContent.scrollTop <= 0 && velocity < 0;
const atBottom = velocity > 0 &&
this.draggedElement.getBoundingClientRect().bottom >=
this.timeGrid.getBoundingClientRect().bottom;
return atTop || atBottom;
}
private setScrollingState(scrolling: boolean): void {
if (this.isScrolling === scrolling) return;
this.isScrolling = scrolling;
if (scrolling) {
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {});
} else {
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
}
}
private scrollTick = (ts: number): void => {
if (!this.isDragging || !this.scrollableContent) return;
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
this.lastTs = ts;
this.rect ??= this.scrollableContent.getBoundingClientRect();
const velocity = this.calculateVelocity();
if (velocity !== 0 && !this.isAtBoundary(velocity)) {
const scrollDelta = velocity * dt;
this.scrollableContent.scrollTop += scrollDelta;
this.rect = null;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_TICK, { scrollDelta });
this.setScrollingState(true);
} else {
this.setScrollingState(false);
}
this.scrollRAF = requestAnimationFrame(this.scrollTick);
};
}

View file

@ -1,229 +0,0 @@
/**
* EventFilterManager - Handles fuzzy search filtering of calendar events
* Uses Fuse.js for fuzzy matching (Apache 2.0 License)
*/
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { ICalendarEvent } from '../types/CalendarTypes';
// Import Fuse.js from npm
import Fuse from 'fuse.js';
interface FuseResult {
item: ICalendarEvent;
refIndex: number;
score?: number;
}
export class EventFilterManager {
private searchInput: HTMLInputElement | null = null;
private allEvents: ICalendarEvent[] = [];
private matchingEventIds: Set<string> = new Set();
private isFilterActive: boolean = false;
private frameRequest: number | null = null;
private fuse: Fuse<ICalendarEvent> | null = null;
constructor() {
// Wait for DOM to be ready before initializing
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.init();
});
} else {
this.init();
}
}
private init(): void {
// Find search input
this.searchInput = document.querySelector('swp-search-container input[type="search"]');
if (!this.searchInput) {
return;
}
// Set up event listeners
this.setupSearchListeners();
this.subscribeToEvents();
// Initialization complete
}
private setupSearchListeners(): void {
if (!this.searchInput) return;
// Listen for input changes
this.searchInput.addEventListener('input', (e) => {
const query = (e.target as HTMLInputElement).value;
this.handleSearchInput(query);
});
// Listen for escape key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.clearFilter();
}
});
}
private subscribeToEvents(): void {
// Listen for events data updates
eventBus.on(CoreEvents.EVENTS_RENDERED, (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.events) {
this.updateEventsList(detail.events);
}
});
}
private updateEventsList(events: ICalendarEvent[]): void {
this.allEvents = events;
// Initialize Fuse with the new events list
this.fuse = new Fuse(this.allEvents, {
keys: ['title', 'description'],
threshold: 0.3,
includeScore: true,
minMatchCharLength: 2, // Minimum 2 characters for a match
shouldSort: true,
ignoreLocation: true // Search anywhere in the string
});
// Re-apply filter if active
if (this.isFilterActive && this.searchInput) {
this.applyFilter(this.searchInput.value);
}
}
private handleSearchInput(query: string): void {
// Cancel any pending filter
if (this.frameRequest) {
cancelAnimationFrame(this.frameRequest);
}
// Debounce with requestAnimationFrame
this.frameRequest = requestAnimationFrame(() => {
if (query.length === 0) {
// Only clear when input is completely empty
this.clearFilter();
} else {
// Let Fuse.js handle minimum character length via minMatchCharLength
this.applyFilter(query);
}
});
}
private applyFilter(query: string): void {
if (!this.fuse) {
return;
}
// Perform fuzzy search
const results = this.fuse.search(query);
// Extract matching event IDs
this.matchingEventIds.clear();
results.forEach((result: FuseResult) => {
if (result.item && result.item.id) {
this.matchingEventIds.add(result.item.id);
}
});
// Update filter state
this.isFilterActive = true;
// Update visual state
this.updateVisualState();
// Emit filter changed event
eventBus.emit(CoreEvents.FILTER_CHANGED, {
active: true,
query: query,
matchingIds: Array.from(this.matchingEventIds)
});
}
private clearFilter(): void {
this.isFilterActive = false;
this.matchingEventIds.clear();
// Clear search input
if (this.searchInput) {
this.searchInput.value = '';
}
// Update visual state
this.updateVisualState();
// Emit filter cleared event
eventBus.emit(CoreEvents.FILTER_CHANGED, {
active: false,
query: '',
matchingIds: []
});
}
private updateVisualState(): void {
// Update search container styling
const searchContainer = document.querySelector('swp-search-container');
if (searchContainer) {
if (this.isFilterActive) {
searchContainer.classList.add('filter-active');
} else {
searchContainer.classList.remove('filter-active');
}
}
// Update all events layers
const eventsLayers = document.querySelectorAll('swp-events-layer');
eventsLayers.forEach(layer => {
if (this.isFilterActive) {
layer.setAttribute('data-filter-active', 'true');
// Mark matching events
const events = layer.querySelectorAll('swp-event');
events.forEach(event => {
const eventId = event.getAttribute('data-event-id');
if (eventId && this.matchingEventIds.has(eventId)) {
event.setAttribute('data-matches', 'true');
} else {
event.removeAttribute('data-matches');
}
});
} else {
layer.removeAttribute('data-filter-active');
// Remove all match attributes
const events = layer.querySelectorAll('swp-event');
events.forEach(event => {
event.removeAttribute('data-matches');
});
}
});
}
/**
* Check if an event matches the current filter
*/
public eventMatchesFilter(eventId: string): boolean {
if (!this.isFilterActive) {
return true; // No filter active, all events match
}
return this.matchingEventIds.has(eventId);
}
/**
* Get current filter state
*/
public getFilterState(): { active: boolean; matchingIds: string[] } {
return {
active: this.isFilterActive,
matchingIds: Array.from(this.matchingEventIds)
};
}
}

View file

@ -1,280 +0,0 @@
/**
* EventLayoutCoordinator - Coordinates event layout calculations
*
* Separates layout logic from rendering concerns.
* Calculates stack levels, groups events, and determines rendering strategy.
*/
import { ICalendarEvent } from '../types/CalendarTypes';
import { EventStackManager, IEventGroup, IStackLink } from './EventStackManager';
import { PositionUtils } from '../utils/PositionUtils';
import { Configuration } from '../configurations/CalendarConfig';
export interface IGridGroupLayout {
events: ICalendarEvent[];
stackLevel: number;
position: { top: number };
columns: ICalendarEvent[][]; // Events grouped by column (events in same array share a column)
}
export interface IStackedEventLayout {
event: ICalendarEvent;
stackLink: IStackLink;
position: { top: number; height: number };
}
export interface IColumnLayout {
gridGroups: IGridGroupLayout[];
stackedEvents: IStackedEventLayout[];
}
export class EventLayoutCoordinator {
private stackManager: EventStackManager;
private config: Configuration;
private positionUtils: PositionUtils;
constructor(stackManager: EventStackManager, config: Configuration, positionUtils: PositionUtils) {
this.stackManager = stackManager;
this.config = config;
this.positionUtils = positionUtils;
}
/**
* Calculate complete layout for a column of events (recursive approach)
*/
public calculateColumnLayout(columnEvents: ICalendarEvent[]): IColumnLayout {
if (columnEvents.length === 0) {
return { gridGroups: [], stackedEvents: [] };
}
const gridGroupLayouts: IGridGroupLayout[] = [];
const stackedEventLayouts: IStackedEventLayout[] = [];
const renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }> = [];
let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
// Process events recursively
while (remaining.length > 0) {
// Take first event
const firstEvent = remaining[0];
// Find events that could be in GRID with first event
// Use expanding search to find chains (A→B→C where each conflicts with next)
const gridSettings = this.config.gridSettings;
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
// Use refactored method for expanding grid candidates
const gridCandidates = this.expandGridCandidates(firstEvent, remaining, thresholdMinutes);
// Decide: should this group be GRID or STACK?
const group: IEventGroup = {
events: gridCandidates,
containerType: 'NONE',
startTime: firstEvent.start
};
const containerType = this.stackManager.decideContainerType(group);
if (containerType === 'GRID' && gridCandidates.length > 1) {
// Render as GRID
const gridStackLevel = this.calculateGridGroupStackLevelFromRendered(
gridCandidates,
renderedEventsWithLevels
);
// Ensure we get the earliest event (explicit sort for robustness)
const earliestEvent = [...gridCandidates].sort((a, b) => a.start.getTime() - b.start.getTime())[0];
const position = this.positionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
const columns = this.allocateColumns(gridCandidates);
gridGroupLayouts.push({
events: gridCandidates,
stackLevel: gridStackLevel,
position: { top: position.top + 1 },
columns
});
// Mark all events in grid with their stack level
gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel }));
// Remove all events in this grid from remaining
remaining = remaining.filter(e => !gridCandidates.includes(e));
} else {
// Render first event as STACKED
const stackLevel = this.calculateStackLevelFromRendered(
firstEvent,
renderedEventsWithLevels
);
const position = this.positionUtils.calculateEventPosition(firstEvent.start, firstEvent.end);
stackedEventLayouts.push({
event: firstEvent,
stackLink: { stackLevel },
position: { top: position.top + 1, height: position.height - 3 }
});
// Mark this event with its stack level
renderedEventsWithLevels.push({ event: firstEvent, level: stackLevel });
// Remove only first event from remaining
remaining = remaining.slice(1);
}
}
return {
gridGroups: gridGroupLayouts,
stackedEvents: stackedEventLayouts
};
}
/**
* Calculate stack level for a grid group based on already rendered events
*/
private calculateGridGroupStackLevelFromRendered(
gridEvents: ICalendarEvent[],
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
): number {
// Find highest stack level of any rendered event that overlaps with this grid
let maxOverlappingLevel = -1;
for (const gridEvent of gridEvents) {
for (const rendered of renderedEventsWithLevels) {
if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
}
}
}
return maxOverlappingLevel + 1;
}
/**
* Calculate stack level for a single stacked event based on already rendered events
*/
private calculateStackLevelFromRendered(
event: ICalendarEvent,
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
): number {
// Find highest stack level of any rendered event that overlaps with this event
let maxOverlappingLevel = -1;
for (const rendered of renderedEventsWithLevels) {
if (this.stackManager.doEventsOverlap(event, rendered.event)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
}
}
return maxOverlappingLevel + 1;
}
/**
* Detect if two events have a conflict based on threshold
*
* @param event1 - First event
* @param event2 - Second event
* @param thresholdMinutes - Threshold in minutes
* @returns true if events conflict
*/
private detectConflict(event1: ICalendarEvent, event2: ICalendarEvent, thresholdMinutes: number): boolean {
// Check 1: Start-to-start conflict (starts within threshold)
const startToStartDiff = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60);
if (startToStartDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(event1, event2)) {
return true;
}
// Check 2: End-to-start conflict (event1 starts within threshold before event2 ends)
const endToStartMinutes = (event2.end.getTime() - event1.start.getTime()) / (1000 * 60);
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
return true;
}
// Check 3: Reverse end-to-start (event2 starts within threshold before event1 ends)
const reverseEndToStart = (event1.end.getTime() - event2.start.getTime()) / (1000 * 60);
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
return true;
}
return false;
}
/**
* Expand grid candidates to find all events connected by conflict chains
*
* Uses expanding search to find chains (ABC where each conflicts with next)
*
* @param firstEvent - The first event to start with
* @param remaining - Remaining events to check
* @param thresholdMinutes - Threshold in minutes
* @returns Array of all events in the conflict chain
*/
private expandGridCandidates(
firstEvent: ICalendarEvent,
remaining: ICalendarEvent[],
thresholdMinutes: number
): ICalendarEvent[] {
const gridCandidates = [firstEvent];
let candidatesChanged = true;
// Keep expanding until no new candidates can be added
while (candidatesChanged) {
candidatesChanged = false;
for (let i = 1; i < remaining.length; i++) {
const candidate = remaining[i];
// Skip if already in candidates
if (gridCandidates.includes(candidate)) continue;
// Check if candidate conflicts with ANY event in gridCandidates
for (const existingCandidate of gridCandidates) {
if (this.detectConflict(candidate, existingCandidate, thresholdMinutes)) {
gridCandidates.push(candidate);
candidatesChanged = true;
break; // Found conflict, move to next candidate
}
}
}
}
return gridCandidates;
}
/**
* Allocate events to columns within a grid group
*
* Events that don't overlap can share the same column.
* Uses a greedy algorithm to minimize the number of columns.
*
* @param events - Events in the grid group (should already be sorted by start time)
* @returns Array of columns, where each column is an array of events
*/
private allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
if (events.length === 0) return [];
if (events.length === 1) return [[events[0]]];
const columns: ICalendarEvent[][] = [];
// For each event, try to place it in an existing column where it doesn't overlap
for (const event of events) {
let placed = false;
// Try to find a column where this event doesn't overlap with any existing event
for (const column of columns) {
const hasOverlap = column.some(colEvent =>
this.stackManager.doEventsOverlap(event, colEvent)
);
if (!hasOverlap) {
column.push(event);
placed = true;
break;
}
}
// If no suitable column found, create a new one
if (!placed) {
columns.push([event]);
}
}
return columns;
}
}

View file

@ -1,199 +0,0 @@
import { IEventBus, ICalendarEvent } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { Configuration } from '../configurations/CalendarConfig';
import { DateService } from '../utils/DateService';
import { EventService } from '../storage/events/EventService';
import { IEntityService } from '../storage/IEntityService';
/**
* EventManager - Event lifecycle and CRUD operations
* Delegates all data operations to EventService
* EventService provides CRUD operations via BaseEntityService (save, delete, getAll)
*/
export class EventManager {
private dateService: DateService;
private config: Configuration;
private eventService: EventService;
constructor(
private eventBus: IEventBus,
dateService: DateService,
config: Configuration,
eventService: IEntityService<ICalendarEvent>
) {
this.dateService = dateService;
this.config = config;
this.eventService = eventService as EventService;
}
/**
* Load event data from service
* Ensures data is loaded (called during initialization)
*/
public async loadData(): Promise<void> {
try {
// Just ensure service is ready - getAll() will return data
await this.eventService.getAll();
} catch (error) {
console.error('Failed to load event data:', error);
throw error;
}
}
/**
* Get all events from service
*/
public async getEvents(copy: boolean = false): Promise<ICalendarEvent[]> {
const events = await this.eventService.getAll();
return copy ? [...events] : events;
}
/**
* Get event by ID from service
*/
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
const event = await this.eventService.get(id);
return event || undefined;
}
/**
* Get event by ID and return event info for navigation
* @param id Event ID to find
* @returns Event with navigation info or null if not found
*/
public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> {
const event = await this.getEventById(id);
if (!event) {
return null;
}
// Validate event dates
const validation = this.dateService.validateDate(event.start);
if (!validation.valid) {
console.warn(`EventManager: Invalid event start date for event ${id}:`, validation.error);
return null;
}
// Validate date range
if (!this.dateService.isValidRange(event.start, event.end)) {
console.warn(`EventManager: Invalid date range for event ${id}: start must be before end`);
return null;
}
return {
event,
eventDate: event.start
};
}
/**
* Navigate to specific event by ID
* Emits navigation events for other managers to handle
* @param eventId Event ID to navigate to
* @returns true if event found and navigation initiated, false otherwise
*/
public async navigateToEvent(eventId: string): Promise<boolean> {
const eventInfo = await this.getEventForNavigation(eventId);
if (!eventInfo) {
console.warn(`EventManager: Event with ID ${eventId} not found`);
return false;
}
const { event, eventDate } = eventInfo;
// Emit navigation request event
this.eventBus.emit(CoreEvents.NAVIGATE_TO_EVENT, {
eventId,
event,
eventDate,
eventStartTime: event.start
});
return true;
}
/**
* Get events that overlap with a given time period
*/
public async getEventsForPeriod(startDate: Date, endDate: Date): Promise<ICalendarEvent[]> {
const events = await this.eventService.getAll();
// Event overlaps period if it starts before period ends AND ends after period starts
return events.filter(event => {
return event.start <= endDate && event.end >= startDate;
});
}
/**
* Create a new event and add it to the calendar
* Generates ID and saves via EventService
*/
public async addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
// Generate unique ID
const id = `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newEvent: ICalendarEvent = {
...event,
id,
syncStatus: 'synced' // No queue yet, mark as synced
};
await this.eventService.save(newEvent);
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
event: newEvent
});
return newEvent;
}
/**
* Update an existing event
* Merges updates with existing event and saves
*/
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
try {
const existingEvent = await this.eventService.get(id);
if (!existingEvent) {
throw new Error(`Event with ID ${id} not found`);
}
const updatedEvent: ICalendarEvent = {
...existingEvent,
...updates,
id, // Ensure ID doesn't change
syncStatus: 'synced' // No queue yet, mark as synced
};
await this.eventService.save(updatedEvent);
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event: updatedEvent
});
return updatedEvent;
} catch (error) {
console.error(`Failed to update event ${id}:`, error);
return null;
}
}
/**
* Delete an event
* Calls EventService.delete()
*/
public async deleteEvent(id: string): Promise<boolean> {
try {
await this.eventService.delete(id);
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
eventId: id
});
return true;
} catch (error) {
console.error(`Failed to delete event ${id}:`, error);
return false;
}
}
}

View file

@ -0,0 +1,102 @@
/**
* 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 { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IDragEndPayload } from '../types/DragTypes';
import { IResizeEndPayload } from '../types/ResizeTypes';
export class EventPersistenceManager {
constructor(
private eventService: EventService,
private eventBus: IEventBus,
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;
const { swpEvent } = payload;
// Get existing event to merge with
const event = await this.eventService.get(swpEvent.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
return;
}
// Parse resourceId from columnKey if present
const { resource } = this.dateService.parseColumnKey(swpEvent.columnKey);
// Update and save - start/end already calculated in SwpEvent
// Set allDay based on drop target:
// - header: allDay = true
// - grid: allDay = false (converts allDay event to timed)
const updatedEvent: ICalendarEvent = {
...event,
start: swpEvent.start,
end: swpEvent.end,
resourceId: resource ?? event.resourceId,
allDay: payload.target === 'header',
syncStatus: 'pending'
};
await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns
const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceColumnKey: payload.sourceColumnKey,
targetColumnKey: swpEvent.columnKey
};
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;
const { swpEvent } = payload;
// Get existing event to merge with
const event = await this.eventService.get(swpEvent.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
return;
}
// Update and save - end already calculated in SwpEvent
const updatedEvent: ICalendarEvent = {
...event,
end: swpEvent.end,
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 updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceColumnKey: swpEvent.columnKey,
targetColumnKey: swpEvent.columnKey
};
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
};
}

View file

@ -1,274 +0,0 @@
/**
* EventStackManager - Manages visual stacking of overlapping calendar events
*
* This class handles the creation and maintenance of "stack chains" - doubly-linked
* lists of overlapping events stored directly in DOM elements via data attributes.
*
* Implements 3-phase algorithm for grid + nested stacking:
* Phase 1: Group events by start time proximity (configurable threshold)
* Phase 2: Decide container type (GRID vs STACKING)
* Phase 3: Handle late arrivals (nested stacking - NOT IMPLEMENTED)
*
* @see STACKING_CONCEPT.md for detailed documentation
* @see stacking-visualization.html for visual examples
*/
import { ICalendarEvent } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
export interface IStackLink {
prev?: string; // Event ID of previous event in stack
next?: string; // Event ID of next event in stack
stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.)
}
export interface IEventGroup {
events: ICalendarEvent[];
containerType: 'NONE' | 'GRID' | 'STACKING';
startTime: Date;
}
export class EventStackManager {
private static readonly STACK_OFFSET_PX = 15;
private config: Configuration;
constructor(config: Configuration) {
this.config = config;
}
// ============================================
// PHASE 1: Start Time Grouping
// ============================================
/**
* Group events by time conflicts (both start-to-start and end-to-start within threshold)
*
* Events are grouped if:
* 1. They start within ±threshold minutes of each other (start-to-start)
* 2. One event starts within threshold minutes before another ends (end-to-start conflict)
*/
public groupEventsByStartTime(events: ICalendarEvent[]): IEventGroup[] {
if (events.length === 0) return [];
// Get threshold from config
const gridSettings = this.config.gridSettings;
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
// Sort events by start time
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const groups: IEventGroup[] = [];
for (const event of sorted) {
// Find existing group that this event conflicts with
const existingGroup = groups.find(group => {
// Check if event conflicts with ANY event in the group
return group.events.some(groupEvent => {
// Start-to-start conflict: events start within threshold
const startToStartMinutes = Math.abs(event.start.getTime() - groupEvent.start.getTime()) / (1000 * 60);
if (startToStartMinutes <= thresholdMinutes) {
return true;
}
// End-to-start conflict: event starts within threshold before groupEvent ends
const endToStartMinutes = (groupEvent.end.getTime() - event.start.getTime()) / (1000 * 60);
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
return true;
}
// Also check reverse: groupEvent starts within threshold before event ends
const reverseEndToStart = (event.end.getTime() - groupEvent.start.getTime()) / (1000 * 60);
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
return true;
}
return false;
});
});
if (existingGroup) {
existingGroup.events.push(event);
} else {
groups.push({
events: [event],
containerType: 'NONE',
startTime: event.start
});
}
}
return groups;
}
// ============================================
// PHASE 2: Container Type Decision
// ============================================
/**
* Decide container type for a group of events
*
* Rule: Events starting simultaneously (within threshold) should ALWAYS use GRID,
* even if they overlap each other. This provides better visual indication that
* events start at the same time.
*/
public decideContainerType(group: IEventGroup): 'NONE' | 'GRID' | 'STACKING' {
if (group.events.length === 1) {
return 'NONE';
}
// If events are grouped together (start within threshold), they should share columns (GRID)
// This is true EVEN if they overlap, because the visual priority is to show
// that they start simultaneously.
return 'GRID';
}
/**
* Check if two events overlap in time
*/
public doEventsOverlap(event1: ICalendarEvent, event2: ICalendarEvent): boolean {
return event1.start < event2.end && event1.end > event2.start;
}
// ============================================
// Stack Level Calculation
// ============================================
/**
* Create optimized stack links (events share levels when possible)
*/
public createOptimizedStackLinks(events: ICalendarEvent[]): Map<string, IStackLink> {
const stackLinks = new Map<string, IStackLink>();
if (events.length === 0) return stackLinks;
// Sort by start time
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
// Step 1: Assign stack levels
for (const event of sorted) {
// Find all events this event overlaps with
const overlapping = sorted.filter(other =>
other !== event && this.doEventsOverlap(event, other)
);
// Find the MINIMUM required level (must be above all overlapping events)
let minRequiredLevel = 0;
for (const other of overlapping) {
const otherLink = stackLinks.get(other.id);
if (otherLink) {
// Must be at least one level above the overlapping event
minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1);
}
}
stackLinks.set(event.id, { stackLevel: minRequiredLevel });
}
// Step 2: Build prev/next chains for overlapping events at adjacent stack levels
for (const event of sorted) {
const currentLink = stackLinks.get(event.id)!;
// Find overlapping events that are directly below (stackLevel - 1)
const overlapping = sorted.filter(other =>
other !== event && this.doEventsOverlap(event, other)
);
const directlyBelow = overlapping.filter(other => {
const otherLink = stackLinks.get(other.id);
return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1;
});
if (directlyBelow.length > 0) {
// Use the first one in sorted order as prev
currentLink.prev = directlyBelow[0].id;
}
// Find overlapping events that are directly above (stackLevel + 1)
const directlyAbove = overlapping.filter(other => {
const otherLink = stackLinks.get(other.id);
return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1;
});
if (directlyAbove.length > 0) {
// Use the first one in sorted order as next
currentLink.next = directlyAbove[0].id;
}
}
return stackLinks;
}
/**
* Calculate marginLeft based on stack level
*/
public calculateMarginLeft(stackLevel: number): number {
return stackLevel * EventStackManager.STACK_OFFSET_PX;
}
/**
* Calculate zIndex based on stack level
*/
public calculateZIndex(stackLevel: number): number {
return 100 + stackLevel;
}
/**
* Serialize stack link to JSON string
*/
public serializeStackLink(stackLink: IStackLink): string {
return JSON.stringify(stackLink);
}
/**
* Deserialize JSON string to stack link
*/
public deserializeStackLink(json: string): IStackLink | null {
try {
return JSON.parse(json);
} catch (e) {
return null;
}
}
/**
* Apply stack link to DOM element
*/
public applyStackLinkToElement(element: HTMLElement, stackLink: IStackLink): void {
element.dataset.stackLink = this.serializeStackLink(stackLink);
}
/**
* Get stack link from DOM element
*/
public getStackLinkFromElement(element: HTMLElement): IStackLink | null {
const data = element.dataset.stackLink;
if (!data) return null;
return this.deserializeStackLink(data);
}
/**
* Apply visual styling to element based on stack level
*/
public applyVisualStyling(element: HTMLElement, stackLevel: number): void {
element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`;
element.style.zIndex = `${this.calculateZIndex(stackLevel)}`;
}
/**
* Clear stack link from element
*/
public clearStackLinkFromElement(element: HTMLElement): void {
delete element.dataset.stackLink;
}
/**
* Clear visual styling from element
*/
public clearVisualStyling(element: HTMLElement): void {
element.style.marginLeft = '';
element.style.zIndex = '';
}
}

View file

@ -1,111 +0,0 @@
/**
* GridManager - Simplified grid manager using centralized GridRenderer
* Delegates DOM rendering to GridRenderer, focuses on coordination
*
* Note: Events are now provided by IColumnDataSource (each column has its own events)
*/
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { CalendarView } from '../types/CalendarTypes';
import { GridRenderer } from '../renderers/GridRenderer';
import { DateService } from '../utils/DateService';
import { IColumnDataSource } from '../types/ColumnDataSource';
import { Configuration } from '../configurations/CalendarConfig';
/**
* Simplified GridManager focused on coordination, delegates rendering to GridRenderer
*/
export class GridManager {
private container: HTMLElement | null = null;
private currentDate: Date = new Date();
private currentView: CalendarView = 'week';
private gridRenderer: GridRenderer;
private dateService: DateService;
private config: Configuration;
private dataSource: IColumnDataSource;
constructor(
gridRenderer: GridRenderer,
dateService: DateService,
config: Configuration,
dataSource: IColumnDataSource
) {
this.gridRenderer = gridRenderer;
this.dateService = dateService;
this.config = config;
this.dataSource = dataSource;
this.init();
}
private init(): void {
this.findElements();
this.subscribeToEvents();
}
private findElements(): void {
this.container = document.querySelector('swp-calendar-container');
}
private subscribeToEvents(): void {
// Listen for view changes
eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentView = detail.currentView;
this.dataSource.setCurrentView(this.currentView);
this.render();
});
// Listen for navigation events from NavigationManager
// NavigationManager has already created new grid with animation
// GridManager only needs to update state, NOT re-render
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentDate = detail.newDate;
this.dataSource.setCurrentDate(this.currentDate);
// Do NOT call render() - NavigationManager already created new grid
});
// Listen for config changes that affect rendering
eventBus.on(CoreEvents.REFRESH_REQUESTED, (e: Event) => {
this.render();
});
eventBus.on(CoreEvents.WORKWEEK_CHANGED, () => {
this.render();
});
}
/**
* Main render method - delegates to GridRenderer
* Note: CSS variables are automatically updated by ConfigManager when config changes
* Note: Events are included in columns from IColumnDataSource
*/
public async render(): Promise<void> {
if (!this.container) {
return;
}
// Get columns from datasource - single source of truth (includes events per column)
const columns = await this.dataSource.getColumns();
// Set grid columns CSS variable based on actual column count
document.documentElement.style.setProperty('--grid-columns', columns.length.toString());
// Delegate to GridRenderer with columns (events are inside each column)
this.gridRenderer.renderGrid(
this.container,
this.currentDate,
this.currentView,
columns
);
// Emit grid rendered event
eventBus.emit(CoreEvents.GRID_RENDERED, {
container: this.container,
currentDate: this.currentDate,
columns: columns
});
}
}

View file

@ -1,138 +0,0 @@
import { eventBus } from '../core/EventBus';
import { Configuration } from '../configurations/CalendarConfig';
import { CoreEvents } from '../constants/CoreEvents';
import { IHeaderRenderer, IHeaderRenderContext } from '../renderers/DateHeaderRenderer';
import { IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IHeaderReadyEventPayload } from '../types/EventTypes';
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { IColumnDataSource } from '../types/ColumnDataSource';
/**
* HeaderManager - Handles all header-related event logic
* Separates event handling from rendering concerns
* Uses dependency injection for renderer strategy
*/
export class HeaderManager {
private headerRenderer: IHeaderRenderer;
private config: Configuration;
private dataSource: IColumnDataSource;
constructor(headerRenderer: IHeaderRenderer, config: Configuration, dataSource: IColumnDataSource) {
this.headerRenderer = headerRenderer;
this.config = config;
this.dataSource = dataSource;
// Bind handler methods for event listeners
this.handleDragMouseEnterHeader = this.handleDragMouseEnterHeader.bind(this);
this.handleDragMouseLeaveHeader = this.handleDragMouseLeaveHeader.bind(this);
// Listen for navigation events to update header
this.setupNavigationListener();
}
/**
* Setup header drag event listeners - Listen to DragDropManager events
*/
public setupHeaderDragListeners(): void {
console.log('🎯 HeaderManager: Setting up drag event listeners');
// Subscribe to drag events from DragDropManager
eventBus.on('drag:mouseenter-header', this.handleDragMouseEnterHeader);
eventBus.on('drag:mouseleave-header', this.handleDragMouseLeaveHeader);
console.log('✅ HeaderManager: Drag event listeners attached');
}
/**
* Handle drag mouse enter header event
*/
private handleDragMouseEnterHeader(event: Event): void {
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
(event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
console.log('🎯 HeaderManager: Received drag:mouseenter-header', {
targetColumn: targetColumn.identifier,
originalElement: !!originalElement,
cloneElement: !!cloneElement
});
}
/**
* Handle drag mouse leave header event
*/
private handleDragMouseLeaveHeader(event: Event): void {
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
(event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail;
console.log('🚪 HeaderManager: Received drag:mouseleave-header', {
targetColumn: targetColumn?.identifier,
originalElement: !!originalElement,
cloneElement: !!cloneElement
});
}
/**
* Setup navigation event listener
*/
private setupNavigationListener(): void {
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event) => {
const { currentDate } = (event as CustomEvent).detail;
this.updateHeader(currentDate);
});
// Also listen for date changes (including initial setup)
eventBus.on(CoreEvents.DATE_CHANGED, (event) => {
const { currentDate } = (event as CustomEvent).detail;
this.updateHeader(currentDate);
});
// Listen for workweek header updates after grid rebuild
//currentDate: this.currentDate,
//currentView: this.currentView,
//workweek: this.config.currentWorkWeek
eventBus.on('workweek:header-update', (event) => {
const { currentDate } = (event as CustomEvent).detail;
this.updateHeader(currentDate);
});
}
/**
* Update header content for navigation
*/
private async updateHeader(currentDate: Date): Promise<void> {
console.log('🎯 HeaderManager.updateHeader called', {
currentDate,
rendererType: this.headerRenderer.constructor.name
});
const calendarHeader = document.querySelector('swp-calendar-header') as HTMLElement;
if (!calendarHeader) {
console.warn('❌ HeaderManager: No calendar header found!');
return;
}
// Clear existing content
calendarHeader.innerHTML = '';
// Update DataSource with current date and get columns
this.dataSource.setCurrentDate(currentDate);
const columns = await this.dataSource.getColumns();
// Render new header content using injected renderer
const context: IHeaderRenderContext = {
columns: columns,
config: this.config
};
this.headerRenderer.render(calendarHeader, context);
// Setup event listeners on the new content
this.setupHeaderDragListeners();
// Notify other managers that header is ready with period data
const payload: IHeaderReadyEventPayload = {
headerElements: ColumnDetectionUtils.getHeaderColumns(),
};
eventBus.emit('header:ready', payload);
}
}

View file

@ -1,258 +0,0 @@
import { IEventBus, CalendarView } from '../types/CalendarTypes';
import { EventRenderingService } from '../renderers/EventRendererManager';
import { DateService } from '../utils/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { WeekInfoRenderer } from '../renderers/WeekInfoRenderer';
import { GridRenderer } from '../renderers/GridRenderer';
import { INavButtonClickedEventPayload } from '../types/EventTypes';
import { IColumnDataSource } from '../types/ColumnDataSource';
import { Configuration } from '../configurations/CalendarConfig';
export class NavigationManager {
private eventBus: IEventBus;
private weekInfoRenderer: WeekInfoRenderer;
private gridRenderer: GridRenderer;
private dateService: DateService;
private config: Configuration;
private dataSource: IColumnDataSource;
private currentWeek: Date;
private targetWeek: Date;
private animationQueue: number = 0;
constructor(
eventBus: IEventBus,
eventRenderer: EventRenderingService,
gridRenderer: GridRenderer,
dateService: DateService,
weekInfoRenderer: WeekInfoRenderer,
config: Configuration,
dataSource: IColumnDataSource
) {
this.eventBus = eventBus;
this.dateService = dateService;
this.weekInfoRenderer = weekInfoRenderer;
this.gridRenderer = gridRenderer;
this.config = config;
this.currentWeek = this.getISOWeekStart(new Date());
this.targetWeek = new Date(this.currentWeek);
this.dataSource = dataSource;
this.init();
}
private init(): void {
this.setupEventListeners();
}
/**
* Get the start of the ISO week (Monday) for a given date
* @param date - Any date in the week
* @returns The Monday of the ISO week
*/
private getISOWeekStart(date: Date): Date {
const weekBounds = this.dateService.getWeekBounds(date);
return this.dateService.startOfDay(weekBounds.start);
}
private setupEventListeners(): void {
// Listen for filter changes and apply to pre-rendered grids
this.eventBus.on(CoreEvents.FILTER_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.weekInfoRenderer.applyFilterToPreRenderedGrids(detail);
});
// Listen for navigation button clicks from NavigationButtons
this.eventBus.on(CoreEvents.NAV_BUTTON_CLICKED, (event: Event) => {
const { direction, newDate } = (event as CustomEvent<INavButtonClickedEventPayload>).detail;
// Navigate to the new date with animation
this.navigateToDate(newDate, direction);
});
// Listen for external navigation requests
this.eventBus.on(CoreEvents.DATE_CHANGED, (event: Event) => {
const customEvent = event as CustomEvent;
const dateFromEvent = customEvent.detail.currentDate;
// Validate date before processing
if (!dateFromEvent) {
console.warn('NavigationManager: No date provided in DATE_CHANGED event');
return;
}
const targetDate = new Date(dateFromEvent);
// Use DateService validation
const validation = this.dateService.validateDate(targetDate);
if (!validation.valid) {
console.warn('NavigationManager: Invalid date received:', validation.error);
return;
}
this.navigateToDate(targetDate);
});
// Listen for event navigation requests
this.eventBus.on(CoreEvents.NAVIGATE_TO_EVENT, (event: Event) => {
const customEvent = event as CustomEvent;
const { eventDate, eventStartTime } = customEvent.detail;
if (!eventDate || !eventStartTime) {
console.warn('NavigationManager: Invalid event navigation data');
return;
}
this.navigateToEventDate(eventDate, eventStartTime);
});
}
/**
* Navigate to specific event date and emit scroll event after navigation
*/
private navigateToEventDate(eventDate: Date, eventStartTime: string): void {
const weekStart = this.getISOWeekStart(eventDate);
this.targetWeek = new Date(weekStart);
const currentTime = this.currentWeek.getTime();
const targetTime = weekStart.getTime();
// Store event start time for scrolling after navigation
const scrollAfterNavigation = () => {
// Emit scroll request after navigation is complete
this.eventBus.emit('scroll:to-event-time', {
eventStartTime
});
};
if (currentTime < targetTime) {
this.animationQueue++;
this.animateTransition('next', weekStart);
// Listen for navigation completion to trigger scroll
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
} else if (currentTime > targetTime) {
this.animationQueue++;
this.animateTransition('prev', weekStart);
// Listen for navigation completion to trigger scroll
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
} else {
// Already on correct week, just scroll
scrollAfterNavigation();
}
}
private navigateToDate(date: Date, direction?: 'next' | 'previous' | 'today'): void {
const weekStart = this.getISOWeekStart(date);
this.targetWeek = new Date(weekStart);
const currentTime = this.currentWeek.getTime();
const targetTime = weekStart.getTime();
// Use provided direction or calculate based on time comparison
let animationDirection: 'next' | 'prev';
if (direction === 'next') {
animationDirection = 'next';
} else if (direction === 'previous') {
animationDirection = 'prev';
} else if (direction === 'today') {
// For "today", determine direction based on current position
animationDirection = currentTime < targetTime ? 'next' : 'prev';
} else {
// Fallback: calculate direction
animationDirection = currentTime < targetTime ? 'next' : 'prev';
}
if (currentTime !== targetTime) {
this.animationQueue++;
this.animateTransition(animationDirection, weekStart);
}
}
/**
* Animation transition using pre-rendered containers when available
*/
private async animateTransition(direction: 'prev' | 'next', targetWeek: Date): Promise<void> {
const container = document.querySelector('swp-calendar-container') as HTMLElement;
const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])') as HTMLElement;
if (!container || !currentGrid) {
return;
}
// Reset all-day height BEFORE creating new grid to ensure base height
const root = document.documentElement;
root.style.setProperty('--all-day-row-height', '0px');
let newGrid: HTMLElement;
console.group('🔧 NavigationManager.refactored');
console.log('Calling GridRenderer instead of NavigationRenderer');
console.log('Target week:', targetWeek);
// Update DataSource with target week and get columns
this.dataSource.setCurrentDate(targetWeek);
const columns = await this.dataSource.getColumns();
// Always create a fresh container for consistent behavior
newGrid = this.gridRenderer.createNavigationGrid(container, columns, targetWeek);
console.groupEnd();
// Clear any existing transforms before animation
newGrid.style.transform = '';
currentGrid.style.transform = '';
// Animate transition using Web Animations API
const slideOutAnimation = currentGrid.animate([
{ transform: 'translateX(0)', opacity: '1' },
{ transform: direction === 'next' ? 'translateX(-100%)' : 'translateX(100%)', opacity: '0.5' }
], {
duration: 400,
easing: 'ease-in-out',
fill: 'forwards'
});
const slideInAnimation = newGrid.animate([
{ transform: direction === 'next' ? 'translateX(100%)' : 'translateX(-100%)' },
{ transform: 'translateX(0)' }
], {
duration: 400,
easing: 'ease-in-out',
fill: 'forwards'
});
// Handle animation completion
slideInAnimation.addEventListener('finish', () => {
// Cleanup: Remove all old grids except the new one
const allGrids = container.querySelectorAll('swp-grid-container');
for (let i = 0; i < allGrids.length - 1; i++) {
allGrids[i].remove();
}
// Reset positioning
newGrid.style.position = 'relative';
newGrid.removeAttribute('data-prerendered');
// Update state
this.currentWeek = new Date(targetWeek);
this.animationQueue--;
// If this was the last queued animation, ensure we're in sync
if (this.animationQueue === 0) {
this.currentWeek = new Date(this.targetWeek);
}
// Emit navigation completed event
this.eventBus.emit(CoreEvents.NAVIGATION_COMPLETED, {
direction,
newDate: this.currentWeek
});
});
}
}

View file

@ -1,244 +0,0 @@
import { eventBus } from '../core/EventBus';
import { Configuration } from '../configurations/CalendarConfig';
import { IResizeEndEventPayload } from '../types/EventTypes';
import { PositionUtils } from '../utils/PositionUtils';
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
export class ResizeHandleManager {
private isResizing = false;
private targetEl: SwpEventEl | null = null;
private startY = 0;
private startDurationMin = 0;
private snapMin: number;
private minDurationMin: number;
private animationId: number | null = null;
private currentHeight = 0;
private targetHeight = 0;
private pointerCaptured = false;
private prevZ?: string;
// Constants for better maintainability
private readonly ANIMATION_SPEED = 0.35;
private readonly Z_INDEX_RESIZING = '1000';
private readonly EVENT_REFRESH_THRESHOLD = 0.5;
constructor(
private config: Configuration,
private positionUtils: PositionUtils
) {
const grid = this.config.gridSettings;
this.snapMin = grid.snapInterval;
this.minDurationMin = this.snapMin;
}
public initialize(): void {
this.attachGlobalListeners();
}
public destroy(): void {
this.removeEventListeners();
}
private removeEventListeners(): void {
const calendarContainer = document.querySelector('swp-calendar-container');
if (calendarContainer) {
calendarContainer.removeEventListener('mouseover', this.onMouseOver, true);
}
document.removeEventListener('pointerdown', this.onPointerDown, true);
document.removeEventListener('pointermove', this.onPointerMove, true);
document.removeEventListener('pointerup', this.onPointerUp, true);
}
private createResizeHandle(): HTMLElement {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
return handle;
}
private attachGlobalListeners(): void {
const calendarContainer = document.querySelector('swp-calendar-container');
if (calendarContainer) {
calendarContainer.addEventListener('mouseover', this.onMouseOver, true);
}
document.addEventListener('pointerdown', this.onPointerDown, true);
document.addEventListener('pointermove', this.onPointerMove, true);
document.addEventListener('pointerup', this.onPointerUp, true);
}
private onMouseOver = (e: Event): void => {
const target = e.target as HTMLElement;
const eventElement = target.closest<SwpEventEl>('swp-event');
if (eventElement && !this.isResizing) {
// Check if handle already exists
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
const handle = this.createResizeHandle();
eventElement.appendChild(handle);
}
}
};
private onPointerDown = (e: PointerEvent): void => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
if (!handle) return;
const element = handle.parentElement as SwpEventEl;
this.startResizing(element, e);
};
private startResizing(element: SwpEventEl, event: PointerEvent): void {
this.targetEl = element;
this.isResizing = true;
this.startY = event.clientY;
const startHeight = element.offsetHeight;
this.startDurationMin = Math.max(
this.minDurationMin,
Math.round(this.positionUtils.pixelsToMinutes(startHeight))
);
this.setZIndexForResizing(element);
this.capturePointer(event);
document.documentElement.classList.add('swp--resizing');
event.preventDefault();
}
private setZIndexForResizing(element: SwpEventEl): void {
const container = element.closest<HTMLElement>('swp-event-group') ?? element;
this.prevZ = container.style.zIndex;
container.style.zIndex = this.Z_INDEX_RESIZING;
}
private capturePointer(event: PointerEvent): void {
try {
(event.target as Element).setPointerCapture?.(event.pointerId);
this.pointerCaptured = true;
} catch (error) {
console.warn('Pointer capture failed:', error);
}
}
private onPointerMove = (e: PointerEvent): void => {
if (!this.isResizing || !this.targetEl) return;
this.updateResizeHeight(e.clientY);
};
private updateResizeHeight(currentY: number): void {
const deltaY = currentY - this.startY;
const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin);
const rawHeight = startHeight + deltaY;
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
this.targetHeight = Math.max(minHeight, rawHeight);
if (this.animationId == null) {
this.currentHeight = this.targetEl?.offsetHeight!!;
this.animate();
}
}
private animate = (): void => {
if (!this.isResizing || !this.targetEl) {
this.animationId = null;
return;
}
const diff = this.targetHeight - this.currentHeight;
if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) {
this.currentHeight += diff * this.ANIMATION_SPEED;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = requestAnimationFrame(this.animate);
} else {
this.finalizeAnimation();
}
};
private finalizeAnimation(): void {
if (!this.targetEl) return;
this.currentHeight = this.targetHeight;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = null;
}
private onPointerUp = (e: PointerEvent): void => {
if (!this.isResizing || !this.targetEl) return;
this.cleanupAnimation();
this.snapToGrid();
this.emitResizeEndEvent();
this.cleanupResizing(e);
};
private cleanupAnimation(): void {
if (this.animationId != null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
private snapToGrid(): void {
if (!this.targetEl) return;
const currentHeight = this.targetEl.offsetHeight;
const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin);
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines
this.targetEl.updateHeight?.(finalHeight);
}
private emitResizeEndEvent(): void {
if (!this.targetEl) return;
const eventId = this.targetEl.dataset.eventId || '';
const resizeEndPayload: IResizeEndEventPayload = {
eventId,
element: this.targetEl,
finalHeight: this.targetEl.offsetHeight
};
eventBus.emit('resize:end', resizeEndPayload);
}
private cleanupResizing(event: PointerEvent): void {
this.restoreZIndex();
this.releasePointer(event);
this.isResizing = false;
this.targetEl = null;
document.documentElement.classList.remove('swp--resizing');
}
private restoreZIndex(): void {
if (!this.targetEl || this.prevZ === undefined) return;
const container = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
container.style.zIndex = this.prevZ;
this.prevZ = undefined;
}
private releasePointer(event: PointerEvent): void {
if (!this.pointerCaptured) return;
try {
(event.target as Element).releasePointerCapture?.(event.pointerId);
this.pointerCaptured = false;
} catch (error) {
console.warn('Pointer release failed:', error);
}
}
}

View file

@ -0,0 +1,290 @@
import { IEventBus } from '../types/CalendarTypes';
import { IGridConfig } from '../core/IGridConfig';
import { pixelsToMinutes, minutesToPixels, snapToGrid } from '../utils/PositionUtils';
import { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IResizeStartPayload, IResizeEndPayload } from '../types/ResizeTypes';
import { SwpEvent } from '../types/SwpEvent';
/**
* ResizeManager - Handles resize of calendar events
*
* Step 1: Handle creation on mouseover (CSS handles visibility)
* Step 2: Pointer events + resize start
* Step 3: RAF animation for smooth height update
* Step 4: Grid snapping + timestamp update
*/
interface ResizeState {
eventId: string;
element: HTMLElement;
handleElement: HTMLElement;
startY: number;
startHeight: number;
startDurationMinutes: number;
pointerId: number;
prevZIndex: string;
// Animation state
currentHeight: number;
targetHeight: number;
animationId: number | null;
}
export class ResizeManager {
private container: HTMLElement | null = null;
private resizeState: ResizeState | null = null;
private readonly Z_INDEX_RESIZING = '1000';
private readonly ANIMATION_SPEED = 0.35;
private readonly MIN_HEIGHT_MINUTES = 15;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private dateService: DateService
) {}
/**
* Initialize resize functionality on container
*/
init(container: HTMLElement): void {
this.container = container;
// Mouseover listener for handle creation (capture phase like V1)
container.addEventListener('mouseover', this.handleMouseOver, true);
// Pointer listeners for resize (capture phase like V1)
document.addEventListener('pointerdown', this.handlePointerDown, true);
document.addEventListener('pointermove', this.handlePointerMove, true);
document.addEventListener('pointerup', this.handlePointerUp, true);
}
/**
* Create resize handle element
*/
private createResizeHandle(): HTMLElement {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
return handle;
}
/**
* Handle mouseover - create resize handle if not exists
*/
private handleMouseOver = (e: Event): void => {
const target = e.target as HTMLElement;
const eventElement = target.closest('swp-event') as HTMLElement;
if (!eventElement || this.resizeState) return;
// Check if handle already exists
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
const handle = this.createResizeHandle();
eventElement.appendChild(handle);
}
};
/**
* Handle pointerdown - start resize if on handle
*/
private handlePointerDown = (e: PointerEvent): void => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle') as HTMLElement;
if (!handle) return;
const element = handle.parentElement as HTMLElement;
if (!element) return;
const eventId = element.dataset.eventId || '';
const startHeight = element.offsetHeight;
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
// Store previous z-index
const container = element.closest('swp-event-group') as HTMLElement ?? element;
const prevZIndex = container.style.zIndex;
// Set resize state
this.resizeState = {
eventId,
element,
handleElement: handle,
startY: e.clientY,
startHeight,
startDurationMinutes,
pointerId: e.pointerId,
prevZIndex,
// Animation state
currentHeight: startHeight,
targetHeight: startHeight,
animationId: null
};
// Elevate z-index
container.style.zIndex = this.Z_INDEX_RESIZING;
// Capture pointer for smooth tracking
try {
handle.setPointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer capture failed:', err);
}
// Add global resizing class
document.documentElement.classList.add('swp--resizing');
// Emit resize start event
this.eventBus.emit(CoreEvents.EVENT_RESIZE_START, {
eventId,
element,
startHeight
} as IResizeStartPayload);
e.preventDefault();
};
/**
* Handle pointermove - update target height during resize
*/
private handlePointerMove = (e: PointerEvent): void => {
if (!this.resizeState) return;
const deltaY = e.clientY - this.resizeState.startY;
const minHeight = (this.MIN_HEIGHT_MINUTES / 60) * this.gridConfig.hourHeight;
const newHeight = Math.max(minHeight, this.resizeState.startHeight + deltaY);
// Set target height for animation
this.resizeState.targetHeight = newHeight;
// Start animation if not running
if (this.resizeState.animationId === null) {
this.animateHeight();
}
};
/**
* RAF animation loop for smooth height interpolation
*/
private animateHeight = (): void => {
if (!this.resizeState) return;
const diff = this.resizeState.targetHeight - this.resizeState.currentHeight;
// Stop animation when close enough
if (Math.abs(diff) < 0.5) {
this.resizeState.animationId = null;
return;
}
// Interpolate towards target (35% per frame like V1)
this.resizeState.currentHeight += diff * this.ANIMATION_SPEED;
this.resizeState.element.style.height = `${this.resizeState.currentHeight}px`;
// Update timestamp display (snapped)
this.updateTimestampDisplay();
// Continue animation
this.resizeState.animationId = requestAnimationFrame(this.animateHeight);
};
/**
* Update timestamp display with snapped end time
*/
private updateTimestampDisplay(): void {
if (!this.resizeState) return;
const timeEl = this.resizeState.element.querySelector('swp-event-time');
if (!timeEl) return;
// Get start time from element position
const top = parseFloat(this.resizeState.element.style.top) || 0;
const startMinutesFromGrid = pixelsToMinutes(top, this.gridConfig);
const startMinutes = (this.gridConfig.dayStartHour * 60) + startMinutesFromGrid;
// Calculate snapped end time from current height
const snappedHeight = snapToGrid(this.resizeState.currentHeight, this.gridConfig);
const durationMinutes = pixelsToMinutes(snappedHeight, this.gridConfig);
const endMinutes = startMinutes + durationMinutes;
// Format and update
const start = this.minutesToDate(startMinutes);
const end = this.minutesToDate(endMinutes);
timeEl.textContent = this.dateService.formatTimeRange(start, end);
}
/**
* Convert minutes since midnight to Date
*/
private minutesToDate(minutes: number): Date {
const date = new Date();
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
return date;
};
/**
* Handle pointerup - finish resize
*/
private handlePointerUp = (e: PointerEvent): void => {
if (!this.resizeState) return;
// Cancel any pending animation
if (this.resizeState.animationId !== null) {
cancelAnimationFrame(this.resizeState.animationId);
}
// Release pointer capture
try {
this.resizeState.handleElement.releasePointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer release failed:', err);
}
// Snap final height to grid
this.snapToGridFinal();
// Update timestamp one final time
this.updateTimestampDisplay();
// Restore z-index
const container = this.resizeState.element.closest('swp-event-group') as HTMLElement ?? this.resizeState.element;
container.style.zIndex = this.resizeState.prevZIndex;
// Remove global resizing class
document.documentElement.classList.remove('swp--resizing');
// Get columnKey and date from parent column
const column = this.resizeState.element.closest('swp-day-column') as HTMLElement;
const columnKey = column?.dataset.columnKey || '';
const date = column?.dataset.date || '';
// Create SwpEvent from element (reads top/height/eventId from element)
const swpEvent = SwpEvent.fromElement(
this.resizeState.element,
columnKey,
date,
this.gridConfig
);
// Emit resize end event
this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, {
swpEvent
} as IResizeEndPayload);
// Reset state
this.resizeState = null;
};
/**
* Snap final height to grid interval
*/
private snapToGridFinal(): void {
if (!this.resizeState) return;
const currentHeight = this.resizeState.element.offsetHeight;
const snappedHeight = snapToGrid(currentHeight, this.gridConfig);
const minHeight = minutesToPixels(this.MIN_HEIGHT_MINUTES, this.gridConfig);
const finalHeight = Math.max(minHeight, snappedHeight);
this.resizeState.element.style.height = `${finalHeight}px`;
this.resizeState.currentHeight = finalHeight;
}
}

View file

@ -1,260 +0,0 @@
// Custom scroll management for calendar week container
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Manages scrolling functionality for the calendar using native scrollbars
*/
export class ScrollManager {
private scrollableContent: HTMLElement | null = null;
private calendarContainer: HTMLElement | null = null;
private timeAxis: HTMLElement | null = null;
private calendarHeader: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private positionUtils: PositionUtils;
constructor(positionUtils: PositionUtils) {
this.positionUtils = positionUtils;
this.init();
}
private init(): void {
this.subscribeToEvents();
}
/**
* Public method to initialize scroll after grid is rendered
*/
public initialize(): void {
this.setupScrolling();
}
private subscribeToEvents(): void {
// Handle navigation animation completion - sync time axis position
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
this.syncTimeAxisPosition();
this.setupScrolling();
});
// Handle all-day row height changes
eventBus.on('header:height-changed', () => {
this.updateScrollableHeight();
});
// Handle header ready - refresh header reference and re-sync
eventBus.on('header:ready', () => {
this.calendarHeader = document.querySelector('swp-calendar-header');
if (this.scrollableContent && this.calendarHeader) {
this.setupHorizontalScrollSynchronization();
this.syncCalendarHeaderPosition(); // Immediately sync position
}
this.updateScrollableHeight(); // Update height calculations
});
// Handle window resize
window.addEventListener('resize', () => {
this.updateScrollableHeight();
});
// Listen for scroll to event time requests
eventBus.on('scroll:to-event-time', (event: Event) => {
const customEvent = event as CustomEvent;
const { eventStartTime } = customEvent.detail;
if (eventStartTime) {
this.scrollToEventTime(eventStartTime);
}
});
}
/**
* Setup scrolling functionality after grid is rendered
*/
private setupScrolling(): void {
this.findElements();
if (this.scrollableContent && this.calendarContainer) {
this.setupResizeObserver();
this.updateScrollableHeight();
this.setupScrollSynchronization();
}
// Setup horizontal scrolling synchronization
if (this.scrollableContent && this.calendarHeader) {
this.setupHorizontalScrollSynchronization();
}
}
/**
* Find DOM elements needed for scrolling
*/
private findElements(): void {
this.scrollableContent = document.querySelector('swp-scrollable-content');
this.calendarContainer = document.querySelector('swp-calendar-container');
this.timeAxis = document.querySelector('swp-time-axis');
this.calendarHeader = document.querySelector('swp-calendar-header');
}
/**
* Scroll to specific position
*/
scrollTo(scrollTop: number): void {
if (!this.scrollableContent) return;
this.scrollableContent.scrollTop = scrollTop;
}
/**
* Scroll to specific hour using PositionUtils
*/
scrollToHour(hour: number): void {
// Create time string for the hour
const timeString = `${hour.toString().padStart(2, '0')}:00`;
const scrollTop = this.positionUtils.timeToPixels(timeString);
this.scrollTo(scrollTop);
}
/**
* Scroll to specific event time
* @param eventStartTime ISO string of event start time
*/
scrollToEventTime(eventStartTime: string): void {
try {
const eventDate = new Date(eventStartTime);
const eventHour = eventDate.getHours();
const eventMinutes = eventDate.getMinutes();
// Convert to decimal hour (e.g., 14:30 becomes 14.5)
const decimalHour = eventHour + (eventMinutes / 60);
this.scrollToHour(decimalHour);
} catch (error) {
console.warn('ScrollManager: Failed to scroll to event time:', error);
}
}
/**
* Setup ResizeObserver to monitor container size changes
*/
private setupResizeObserver(): void {
if (!this.calendarContainer) return;
// Clean up existing observer
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.updateScrollableHeight();
}
});
this.resizeObserver.observe(this.calendarContainer);
}
/**
* Calculate and update scrollable content height dynamically
*/
private updateScrollableHeight(): void {
if (!this.scrollableContent || !this.calendarContainer) return;
// Get calendar container height
const containerRect = this.calendarContainer.getBoundingClientRect();
// Find navigation height
const navigation = document.querySelector('swp-calendar-nav');
const navHeight = navigation ? navigation.getBoundingClientRect().height : 0;
// Find calendar header height
const calendarHeaderElement = document.querySelector('swp-calendar-header');
const headerHeight = calendarHeaderElement ? calendarHeaderElement.getBoundingClientRect().height : 80;
// Calculate available height for scrollable content
const availableHeight = containerRect.height - headerHeight;
// Calculate available width (container width minus time-axis)
const availableWidth = containerRect.width - 60; // 60px time-axis
// Set the height and width on scrollable content
if (availableHeight > 0) {
this.scrollableContent.style.height = `${availableHeight}px`;
}
if (availableWidth > 0) {
this.scrollableContent.style.width = `${availableWidth}px`;
}
}
/**
* Setup scroll synchronization between scrollable content and time axis
*/
private setupScrollSynchronization(): void {
if (!this.scrollableContent || !this.timeAxis) return;
// Throttle scroll events for better performance
let scrollTimeout: number | null = null;
this.scrollableContent.addEventListener('scroll', () => {
if (scrollTimeout) {
cancelAnimationFrame(scrollTimeout);
}
scrollTimeout = requestAnimationFrame(() => {
this.syncTimeAxisPosition();
});
});
}
/**
* Synchronize time axis position with scrollable content
*/
private syncTimeAxisPosition(): void {
if (!this.scrollableContent || !this.timeAxis) return;
const scrollTop = this.scrollableContent.scrollTop;
const timeAxisContent = this.timeAxis.querySelector('swp-time-axis-content');
if (timeAxisContent) {
// Use transform for smooth performance
(timeAxisContent as HTMLElement).style.transform = `translateY(-${scrollTop}px)`;
// Debug logging (can be removed later)
if (scrollTop % 100 === 0) { // Only log every 100px to avoid spam
}
}
}
/**
* Setup horizontal scroll synchronization between scrollable content and calendar header
*/
private setupHorizontalScrollSynchronization(): void {
if (!this.scrollableContent || !this.calendarHeader) return;
// Listen to horizontal scroll events
this.scrollableContent.addEventListener('scroll', () => {
this.syncCalendarHeaderPosition();
});
}
/**
* Synchronize calendar header position with scrollable content horizontal scroll
*/
private syncCalendarHeaderPosition(): void {
if (!this.scrollableContent || !this.calendarHeader) return;
const scrollLeft = this.scrollableContent.scrollLeft;
// Use transform for smooth performance
this.calendarHeader.style.transform = `translateX(-${scrollLeft}px)`;
// Debug logging (can be removed later)
if (scrollLeft % 100 === 0) { // Only log every 100px to avoid spam
}
}
}

View file

@ -1,162 +0,0 @@
// Work hours management for per-column scheduling
import { DateService } from '../utils/DateService';
import { Configuration } from '../configurations/CalendarConfig';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Work hours for a specific day
*/
export interface IDayWorkHours {
start: number; // Hour (0-23)
end: number; // Hour (0-23)
}
/**
* Work schedule configuration
*/
export interface IWorkScheduleConfig {
weeklyDefault: {
monday: IDayWorkHours | 'off';
tuesday: IDayWorkHours | 'off';
wednesday: IDayWorkHours | 'off';
thursday: IDayWorkHours | 'off';
friday: IDayWorkHours | 'off';
saturday: IDayWorkHours | 'off';
sunday: IDayWorkHours | 'off';
};
dateOverrides: {
[dateString: string]: IDayWorkHours | 'off'; // YYYY-MM-DD format
};
}
/**
* Manages work hours scheduling with weekly defaults and date-specific overrides
*/
export class WorkHoursManager {
private dateService: DateService;
private config: Configuration;
private positionUtils: PositionUtils;
private workSchedule: IWorkScheduleConfig;
constructor(dateService: DateService, config: Configuration, positionUtils: PositionUtils) {
this.dateService = dateService;
this.config = config;
this.positionUtils = positionUtils;
// Default work schedule - will be loaded from JSON later
this.workSchedule = {
weeklyDefault: {
monday: { start: 9, end: 17 },
tuesday: { start: 9, end: 17 },
wednesday: { start: 9, end: 17 },
thursday: { start: 9, end: 17 },
friday: { start: 9, end: 15 },
saturday: 'off',
sunday: 'off'
},
dateOverrides: {
'2025-01-20': { start: 10, end: 16 },
'2025-01-21': { start: 8, end: 14 },
'2025-01-22': 'off'
}
};
}
/**
* Get work hours for a specific date
*/
getWorkHoursForDate(date: Date): IDayWorkHours | 'off' {
const dateString = this.dateService.formatISODate(date);
// Check for date-specific override first
if (this.workSchedule.dateOverrides[dateString]) {
return this.workSchedule.dateOverrides[dateString];
}
// Fall back to weekly default
const dayName = this.getDayName(date);
return this.workSchedule.weeklyDefault[dayName];
}
/**
* Get work hours for multiple dates (used by GridManager)
*/
getWorkHoursForDateRange(dates: Date[]): Map<string, IDayWorkHours | 'off'> {
const workHoursMap = new Map<string, IDayWorkHours | 'off'>();
dates.forEach(date => {
const dateString = this.dateService.formatISODate(date);
const workHours = this.getWorkHoursForDate(date);
workHoursMap.set(dateString, workHours);
});
return workHoursMap;
}
/**
* Calculate CSS custom properties for non-work hour overlays using PositionUtils
*/
calculateNonWorkHoursStyle(workHours: IDayWorkHours | 'off'): { beforeWorkHeight: number; afterWorkTop: number } | null {
if (workHours === 'off') {
return null; // Full day will be colored via CSS background
}
const gridSettings = this.config.gridSettings;
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
// Before work: from day start to work start
const beforeWorkHeight = (workHours.start - dayStartHour) * hourHeight;
// After work: from work end to day end
const afterWorkTop = (workHours.end - dayStartHour) * hourHeight;
return {
beforeWorkHeight: Math.max(0, beforeWorkHeight),
afterWorkTop: Math.max(0, afterWorkTop)
};
}
/**
* Calculate CSS custom properties for work hours overlay using PositionUtils
*/
calculateWorkHoursStyle(workHours: IDayWorkHours | 'off'): { top: number; height: number } | null {
if (workHours === 'off') {
return null;
}
// Create dummy time strings for start and end of work hours
const startTime = `${workHours.start.toString().padStart(2, '0')}:00`;
const endTime = `${workHours.end.toString().padStart(2, '0')}:00`;
// Use PositionUtils for consistent position calculation
const position = this.positionUtils.calculateEventPosition(startTime, endTime);
return { top: position.top, height: position.height };
}
/**
* Load work schedule from JSON (future implementation)
*/
async loadWorkSchedule(jsonData: IWorkScheduleConfig): Promise<void> {
this.workSchedule = jsonData;
}
/**
* Get current work schedule configuration
*/
getWorkSchedule(): IWorkScheduleConfig {
return this.workSchedule;
}
/**
* Convert Date to day name key
*/
private getDayName(date: Date): keyof IWorkScheduleConfig['weeklyDefault'] {
const dayNames: (keyof IWorkScheduleConfig['weeklyDefault'])[] = [
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'
];
return dayNames[date.getDay()];
}
}