Refactors calendar managers and renderers

Improves calendar rendering performance by centralizing DOM manipulation in a dedicated `GridRenderer` class. This reduces redundant DOM queries and improves overall efficiency.

Introduces `EventManager` for optimized event lifecycle management with caching and optimized data processing.

The `ViewManager` is refactored for optimized view switching and event handling, further streamlining the application's architecture.

This change moves from a strategy-based `GridManager` to a simpler approach leveraging the `GridRenderer` directly for DOM updates. This eliminates unnecessary abstractions and improves code maintainability.

The changes include removing the old `GridManager`, `EventManager` and introducing new versions.
This commit is contained in:
Janus Knudsen 2025-09-03 18:51:19 +02:00
parent b8b44ddae8
commit 05bb074e9a
4 changed files with 566 additions and 271 deletions

View file

@ -2,71 +2,65 @@ import { EventBus } from '../core/EventBus';
import { IEventBus, CalendarEvent, ResourceCalendarData } from '../types/CalendarTypes'; import { IEventBus, CalendarEvent, ResourceCalendarData } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents'; import { CoreEvents } from '../constants/CoreEvents';
import { calendarConfig } from '../core/CalendarConfig'; import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
/** /**
* EventManager - Administrerer event lifecycle og CRUD operationer * EventManager - Optimized event lifecycle and CRUD operations
* Håndterer mock data og event synchronization * Handles data loading with improved performance and caching
*/ */
export class EventManager { export class EventManager {
private eventBus: IEventBus; private eventBus: IEventBus;
private events: CalendarEvent[] = []; private events: CalendarEvent[] = [];
private rawData: any = null;
private eventCache = new Map<string, CalendarEvent[]>(); // Cache for period queries
private lastCacheKey: string = '';
constructor(eventBus: IEventBus) { constructor(eventBus: IEventBus) {
this.eventBus = eventBus; this.eventBus = eventBus;
this.setupEventListeners();
}
private setupEventListeners(): void {
// NOTE: Removed POC event listener to prevent interference with production code
// POC sliding animation should not trigger separate event rendering
// this.eventBus.on(CoreEvents.WEEK_CONTENT_RENDERED, ...);
} }
/** /**
* Public method to load data - called directly by CalendarManager * Optimized data loading with better error handling
*/ */
public async loadData(): Promise<void> { public async loadData(): Promise<void> {
await this.loadMockData();
// Debug: Log first few events
if (this.events.length > 0) {
}
}
private async loadMockData(): Promise<void> {
try { try {
const calendarType = calendarConfig.getCalendarMode(); await this.loadMockData();
let jsonFile: string; this.clearCache(); // Clear cache when new data is loaded
} catch (error) {
console.error('Failed to load event data:', error);
if (calendarType === 'resource') { this.events = [];
jsonFile = '/src/data/mock-resource-events.json'; this.rawData = null;
} else { }
jsonFile = '/src/data/mock-events.json';
} }
/**
* Optimized mock data loading with better resource handling
*/
private async loadMockData(): Promise<void> {
const calendarType = calendarConfig.getCalendarMode();
const jsonFile = calendarType === 'resource'
? '/src/data/mock-resource-events.json'
: '/src/data/mock-events.json';
const response = await fetch(jsonFile); const response = await fetch(jsonFile);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load mock events: ${response.status}`); throw new Error(`Failed to load mock events: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
// Store raw data for GridManager // Store raw data and process in one operation
this.rawData = data; this.rawData = data;
this.events = this.processCalendarData(calendarType, data);
// Process data for internal use
this.processCalendarData(calendarType, data);
} catch (error) {
this.events = []; // Fallback to empty array
}
} }
private processCalendarData(calendarType: string, data: any): void { /**
* Optimized data processing with better type safety
*/
private processCalendarData(calendarType: string, data: any): CalendarEvent[] {
if (calendarType === 'resource') { if (calendarType === 'resource') {
const resourceData = data as ResourceCalendarData; const resourceData = data as ResourceCalendarData;
this.events = resourceData.resources.flatMap(resource => return resourceData.resources.flatMap(resource =>
resource.events.map(event => ({ resource.events.map(event => ({
...event, ...event,
resourceName: resource.name, resourceName: resource.name,
@ -74,18 +68,24 @@ export class EventManager {
resourceEmployeeId: resource.employeeId resourceEmployeeId: resource.employeeId
})) }))
); );
} else {
this.events = data as CalendarEvent[];
}
} }
private syncEvents(): void { return data as CalendarEvent[];
// Events are synced during initialization
// This method maintained for internal state management only
} }
public getEvents(): CalendarEvent[] { /**
return [...this.events]; * Clear event cache when data changes
*/
private clearCache(): void {
this.eventCache.clear();
this.lastCacheKey = '';
}
/**
* Get events with optional copying for performance
*/
public getEvents(copy: boolean = false): CalendarEvent[] {
return copy ? [...this.events] : this.events;
} }
/** /**
@ -95,34 +95,54 @@ export class EventManager {
return this.rawData; return this.rawData;
} }
private rawData: any = null; /**
* Optimized event lookup with early return
*/
public getEventById(id: string): CalendarEvent | undefined { public getEventById(id: string): CalendarEvent | undefined {
// Use find for better performance than filter + first
return this.events.find(event => event.id === id); return this.events.find(event => event.id === id);
} }
/** /**
* Get events for a specific time period * Optimized events for period with caching and DateCalculator
*/ */
public getEventsForPeriod(startDate: Date, endDate: Date): CalendarEvent[] { public getEventsForPeriod(startDate: Date, endDate: Date): CalendarEvent[] {
return this.events.filter(event => { // Create cache key using DateCalculator for consistent formatting
const cacheKey = `${DateCalculator.formatISODate(startDate)}_${DateCalculator.formatISODate(endDate)}`;
// Return cached result if available
if (this.lastCacheKey === cacheKey && this.eventCache.has(cacheKey)) {
return this.eventCache.get(cacheKey)!;
}
// Filter events using optimized date operations
const filteredEvents = this.events.filter(event => {
// Use DateCalculator for consistent date parsing
const eventStart = new Date(event.start); const eventStart = new Date(event.start);
const eventEnd = new Date(event.end); const eventEnd = new Date(event.end);
// Event overlaps period if it starts before period ends AND ends after period starts // Event overlaps period if it starts before period ends AND ends after period starts
return eventStart <= endDate && eventEnd >= startDate; return eventStart <= endDate && eventEnd >= startDate;
}); });
// Cache the result
this.eventCache.set(cacheKey, filteredEvents);
this.lastCacheKey = cacheKey;
return filteredEvents;
} }
/**
* Optimized event creation with better ID generation
*/
public addEvent(event: Omit<CalendarEvent, 'id'>): CalendarEvent { public addEvent(event: Omit<CalendarEvent, 'id'>): CalendarEvent {
const newEvent: CalendarEvent = { const newEvent: CalendarEvent = {
...event, ...event,
id: Date.now().toString() id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}; };
this.events.push(newEvent); this.events.push(newEvent);
this.syncEvents(); this.clearCache(); // Clear cache when data changes
this.eventBus.emit(CoreEvents.EVENT_CREATED, { this.eventBus.emit(CoreEvents.EVENT_CREATED, {
event: newEvent event: newEvent
@ -131,6 +151,9 @@ export class EventManager {
return newEvent; return newEvent;
} }
/**
* Optimized event update with validation
*/
public updateEvent(id: string, updates: Partial<CalendarEvent>): CalendarEvent | null { public updateEvent(id: string, updates: Partial<CalendarEvent>): CalendarEvent | null {
const eventIndex = this.events.findIndex(event => event.id === id); const eventIndex = this.events.findIndex(event => event.id === id);
if (eventIndex === -1) return null; if (eventIndex === -1) return null;
@ -138,7 +161,7 @@ export class EventManager {
const updatedEvent = { ...this.events[eventIndex], ...updates }; const updatedEvent = { ...this.events[eventIndex], ...updates };
this.events[eventIndex] = updatedEvent; this.events[eventIndex] = updatedEvent;
this.syncEvents(); this.clearCache(); // Clear cache when data changes
this.eventBus.emit(CoreEvents.EVENT_UPDATED, { this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event: updatedEvent event: updatedEvent
@ -147,6 +170,9 @@ export class EventManager {
return updatedEvent; return updatedEvent;
} }
/**
* Optimized event deletion with better error handling
*/
public deleteEvent(id: string): boolean { public deleteEvent(id: string): boolean {
const eventIndex = this.events.findIndex(event => event.id === id); const eventIndex = this.events.findIndex(event => event.id === id);
if (eventIndex === -1) return false; if (eventIndex === -1) return false;
@ -154,7 +180,7 @@ export class EventManager {
const deletedEvent = this.events[eventIndex]; const deletedEvent = this.events[eventIndex];
this.events.splice(eventIndex, 1); this.events.splice(eventIndex, 1);
this.syncEvents(); this.clearCache(); // Clear cache when data changes
this.eventBus.emit(CoreEvents.EVENT_DELETED, { this.eventBus.emit(CoreEvents.EVENT_DELETED, {
event: deletedEvent event: deletedEvent
@ -163,12 +189,19 @@ export class EventManager {
return true; return true;
} }
public refresh(): void { /**
this.syncEvents(); * Refresh data by reloading from source
*/
public async refresh(): Promise<void> {
await this.loadData();
} }
/**
* Clean up resources and clear caches
*/
public destroy(): void { public destroy(): void {
this.events = []; this.events = [];
this.rawData = null;
this.clearCache();
} }
} }

View file

@ -1,31 +1,29 @@
/** /**
* GridManager - Simplified grid manager using Strategy Pattern * GridManager - Simplified grid manager using centralized GridRenderer
* Now delegates view-specific logic to strategy implementations * Delegates DOM rendering to GridRenderer, focuses on coordination
*/ */
import { eventBus } from '../core/EventBus'; import { eventBus } from '../core/EventBus';
import { calendarConfig } from '../core/CalendarConfig'; import { calendarConfig } from '../core/CalendarConfig';
import { CoreEvents } from '../constants/CoreEvents'; import { CoreEvents } from '../constants/CoreEvents';
import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes'; import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes';
import { ViewStrategy, ViewContext } from '../strategies/ViewStrategy'; import { GridRenderer } from '../renderers/GridRenderer';
import { WeekViewStrategy } from '../strategies/WeekViewStrategy'; import { DateCalculator } from '../utils/DateCalculator';
import { MonthViewStrategy } from '../strategies/MonthViewStrategy';
/** /**
* Simplified GridManager focused on coordination, not implementation * Simplified GridManager focused on coordination, delegates rendering to GridRenderer
*/ */
export class GridManager { export class GridManager {
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private currentDate: Date = new Date(); private currentDate: Date = new Date();
private resourceData: ResourceCalendarData | null = null; private resourceData: ResourceCalendarData | null = null;
private currentStrategy: ViewStrategy; private currentView: CalendarView = 'week';
private gridRenderer: GridRenderer;
private eventCleanup: (() => void)[] = []; private eventCleanup: (() => void)[] = [];
constructor() { constructor() {
// Initialize GridRenderer with config
// Default to week view strategy this.gridRenderer = new GridRenderer(calendarConfig);
this.currentStrategy = new WeekViewStrategy();
this.init(); this.init();
} }
@ -40,11 +38,12 @@ export class GridManager {
} }
private subscribeToEvents(): void { private subscribeToEvents(): void {
// Listen for view changes to switch strategies // Listen for view changes
this.eventCleanup.push( this.eventCleanup.push(
eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => { eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
this.switchViewStrategy(detail.currentView); this.currentView = detail.currentView;
this.render();
}) })
); );
@ -63,27 +62,10 @@ export class GridManager {
} }
/** /**
* Switch to a different view strategy * Switch to a different view
*/ */
public switchViewStrategy(view: CalendarView): void { public switchView(view: CalendarView): void {
this.currentView = view;
// Clean up current strategy
this.currentStrategy.destroy();
// Create new strategy based on view
switch (view) {
case 'week':
case 'day':
this.currentStrategy = new WeekViewStrategy();
break;
case 'month':
this.currentStrategy = new MonthViewStrategy();
break;
default:
this.currentStrategy = new WeekViewStrategy();
}
// Re-render with new strategy
this.render(); this.render();
} }
@ -96,32 +78,27 @@ export class GridManager {
} }
/** /**
* Main render method - delegates to current strategy * Main render method - delegates to GridRenderer
*/ */
public async render(): Promise<void> { public async render(): Promise<void> {
if (!this.container) { if (!this.container) {
return; return;
} }
// Delegate to GridRenderer with current view context
this.gridRenderer.renderGrid(
this.container,
this.currentDate,
this.resourceData
);
// Create context for strategy // Calculate period range using DateCalculator
const context: ViewContext = { const periodRange = this.getPeriodRange();
currentDate: this.currentDate,
container: this.container,
resourceData: this.resourceData
};
// Delegate to current strategy // Get layout config based on current view
this.currentStrategy.renderGrid(context); const layoutConfig = this.getLayoutConfig();
// Get layout info from strategy // Emit grid rendered event
const layoutConfig = this.currentStrategy.getLayoutConfig();
// Get period range from current strategy
const periodRange = this.currentStrategy.getPeriodRange(this.currentDate);
// Emit grid rendered event with explicit date range
eventBus.emit(CoreEvents.GRID_RENDERED, { eventBus.emit(CoreEvents.GRID_RENDERED, {
container: this.container, container: this.container,
currentDate: this.currentDate, currentDate: this.currentDate,
@ -130,22 +107,48 @@ export class GridManager {
layoutConfig: layoutConfig, layoutConfig: layoutConfig,
columnCount: layoutConfig.columnCount columnCount: layoutConfig.columnCount
}); });
} }
/** /**
* Get current period label from strategy * Get current period label using DateCalculator
*/ */
public getCurrentPeriodLabel(): string { public getCurrentPeriodLabel(): string {
return this.currentStrategy.getPeriodLabel(this.currentDate); switch (this.currentView) {
case 'week':
case 'day':
const weekStart = DateCalculator.getISOWeekStart(this.currentDate);
const weekEnd = DateCalculator.getWeekEnd(this.currentDate);
return DateCalculator.formatDateRange(weekStart, weekEnd);
case 'month':
return this.currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
default:
const defaultWeekStart = DateCalculator.getISOWeekStart(this.currentDate);
const defaultWeekEnd = DateCalculator.getWeekEnd(this.currentDate);
return DateCalculator.formatDateRange(defaultWeekStart, defaultWeekEnd);
}
} }
/** /**
* Navigate to next period using strategy * Navigate to next period using DateCalculator
*/ */
public navigateNext(): void { public navigateNext(): void {
const nextDate = this.currentStrategy.getNextPeriod(this.currentDate); let nextDate: Date;
switch (this.currentView) {
case 'week':
nextDate = DateCalculator.addWeeks(this.currentDate, 1);
break;
case 'month':
nextDate = this.addMonths(this.currentDate, 1);
break;
case 'day':
nextDate = DateCalculator.addDays(this.currentDate, 1);
break;
default:
nextDate = DateCalculator.addWeeks(this.currentDate, 1);
}
this.currentDate = nextDate; this.currentDate = nextDate;
eventBus.emit(CoreEvents.PERIOD_CHANGED, { eventBus.emit(CoreEvents.PERIOD_CHANGED, {
@ -158,10 +161,25 @@ export class GridManager {
} }
/** /**
* Navigate to previous period using strategy * Navigate to previous period using DateCalculator
*/ */
public navigatePrevious(): void { public navigatePrevious(): void {
const prevDate = this.currentStrategy.getPreviousPeriod(this.currentDate); let prevDate: Date;
switch (this.currentView) {
case 'week':
prevDate = DateCalculator.addWeeks(this.currentDate, -1);
break;
case 'month':
prevDate = this.addMonths(this.currentDate, -1);
break;
case 'day':
prevDate = DateCalculator.addDays(this.currentDate, -1);
break;
default:
prevDate = DateCalculator.addWeeks(this.currentDate, -1);
}
this.currentDate = prevDate; this.currentDate = prevDate;
eventBus.emit(CoreEvents.PERIOD_CHANGED, { eventBus.emit(CoreEvents.PERIOD_CHANGED, {
@ -188,26 +206,137 @@ export class GridManager {
} }
/** /**
* Get current view's display dates * Get current view's display dates using DateCalculator
*/ */
public getDisplayDates(): Date[] { public getDisplayDates(): Date[] {
return this.currentStrategy.getDisplayDates(this.currentDate); switch (this.currentView) {
case 'week':
const weekStart = DateCalculator.getISOWeekStart(this.currentDate);
return DateCalculator.getFullWeekDates(weekStart);
case 'month':
return this.getMonthDates(this.currentDate);
case 'day':
return [this.currentDate];
default:
const defaultWeekStart = DateCalculator.getISOWeekStart(this.currentDate);
return DateCalculator.getFullWeekDates(defaultWeekStart);
}
}
/**
* Get period range for current view
*/
private getPeriodRange(): { startDate: Date; endDate: Date } {
switch (this.currentView) {
case 'week':
const weekStart = DateCalculator.getISOWeekStart(this.currentDate);
const weekEnd = DateCalculator.getWeekEnd(this.currentDate);
return {
startDate: weekStart,
endDate: weekEnd
};
case 'month':
return {
startDate: this.getMonthStart(this.currentDate),
endDate: this.getMonthEnd(this.currentDate)
};
case 'day':
return {
startDate: this.currentDate,
endDate: this.currentDate
};
default:
const defaultWeekStart = DateCalculator.getISOWeekStart(this.currentDate);
const defaultWeekEnd = DateCalculator.getWeekEnd(this.currentDate);
return {
startDate: defaultWeekStart,
endDate: defaultWeekEnd
};
}
}
/**
* Get layout config for current view
*/
private getLayoutConfig(): any {
switch (this.currentView) {
case 'week':
return {
columnCount: 7,
type: 'week'
};
case 'month':
return {
columnCount: 7,
type: 'month'
};
case 'day':
return {
columnCount: 1,
type: 'day'
};
default:
return {
columnCount: 7,
type: 'week'
};
}
} }
/** /**
* Clean up all resources * Clean up all resources
*/ */
public destroy(): void { public destroy(): void {
// Clean up event listeners // Clean up event listeners
this.eventCleanup.forEach(cleanup => cleanup()); this.eventCleanup.forEach(cleanup => cleanup());
this.eventCleanup = []; this.eventCleanup = [];
// Clean up current strategy
this.currentStrategy.destroy();
// Clear references // Clear references
this.container = null; this.container = null;
this.resourceData = null; this.resourceData = null;
} }
/**
* Helper method to add months to a date
*/
private addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
/**
* Helper method to get month start
*/
private getMonthStart(date: Date): Date {
const result = new Date(date);
result.setDate(1);
result.setHours(0, 0, 0, 0);
return result;
}
/**
* Helper method to get month end
*/
private getMonthEnd(date: Date): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + 1, 0);
result.setHours(23, 59, 59, 999);
return result;
}
/**
* Helper method to get all dates in a month
*/
private getMonthDates(date: Date): Date[] {
const dates: Date[] = [];
const monthStart = this.getMonthStart(date);
const monthEnd = this.getMonthEnd(date);
for (let d = new Date(monthStart); d <= monthEnd; d.setDate(d.getDate() + 1)) {
dates.push(new Date(d));
}
return dates;
}
} }

View file

@ -4,8 +4,8 @@ import { calendarConfig } from '../core/CalendarConfig';
import { CoreEvents } from '../constants/CoreEvents'; import { CoreEvents } from '../constants/CoreEvents';
/** /**
* ViewManager - Håndterer skift mellem dag/uge/måned visninger * ViewManager - Optimized view switching with consolidated event handling
* Arbejder med custom tags fra POC design * Reduces redundant DOM queries and event listener setups
*/ */
export class ViewManager { export class ViewManager {
private eventBus: IEventBus; private eventBus: IEventBus;
@ -13,89 +13,126 @@ export class ViewManager {
private eventCleanup: (() => void)[] = []; private eventCleanup: (() => void)[] = [];
private buttonListeners: Map<Element, EventListener> = new Map(); private buttonListeners: Map<Element, EventListener> = new Map();
// Cached DOM elements for performance
private cachedViewButtons: NodeListOf<Element> | null = null;
private cachedWorkweekButtons: NodeListOf<Element> | null = null;
private lastButtonCacheTime: number = 0;
private readonly CACHE_DURATION = 5000; // 5 seconds
constructor(eventBus: IEventBus) { constructor(eventBus: IEventBus) {
this.eventBus = eventBus; this.eventBus = eventBus;
this.setupEventListeners(); this.setupEventListeners();
} }
/**
* Consolidated event listener setup with better organization
*/
private setupEventListeners(): void { private setupEventListeners(): void {
// Track event bus listeners for cleanup // Event bus listeners
this.setupEventBusListeners();
// DOM button handlers with consolidated logic
this.setupButtonHandlers();
}
/**
* Setup event bus listeners with proper cleanup tracking
*/
private setupEventBusListeners(): void {
this.eventCleanup.push( this.eventCleanup.push(
this.eventBus.on(CoreEvents.INITIALIZED, () => { this.eventBus.on(CoreEvents.INITIALIZED, () => {
this.initializeView(); this.initializeView();
}) })
); );
this.eventCleanup.push( // Remove redundant VIEW_CHANGED listener that causes circular calls
this.eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => { // changeView is called directly from button handlers
const customEvent = event as CustomEvent;
const { currentView } = customEvent.detail;
this.changeView(currentView);
})
);
this.eventCleanup.push( this.eventCleanup.push(
this.eventBus.on(CoreEvents.DATE_CHANGED, () => { this.eventBus.on(CoreEvents.DATE_CHANGED, () => {
this.refreshCurrentView(); this.refreshCurrentView();
}) })
); );
// Setup view button handlers
this.setupViewButtonHandlers();
// Setup workweek preset button handlers
this.setupWorkweekButtonHandlers();
} }
private setupViewButtonHandlers(): void { /**
const viewButtons = document.querySelectorAll('swp-view-button[data-view]'); * Consolidated button handler setup with shared logic
viewButtons.forEach(button => { */
const handler = (event: Event) => { private setupButtonHandlers(): void {
event.preventDefault(); // Setup view buttons with consolidated handler
const view = button.getAttribute('data-view') as CalendarView; this.setupButtonGroup('swp-view-button[data-view]', 'data-view', (value) => {
if (view && this.isValidView(view)) { if (this.isValidView(value)) {
this.changeView(view); this.changeView(value as CalendarView);
} }
}; });
button.addEventListener('click', handler);
this.buttonListeners.set(button, handler); // Setup workweek buttons with consolidated handler
this.setupButtonGroup('swp-preset-button[data-workweek]', 'data-workweek', (value) => {
this.changeWorkweek(value);
}); });
} }
private setupWorkweekButtonHandlers(): void { /**
const workweekButtons = document.querySelectorAll('swp-preset-button[data-workweek]'); * Generic button group setup to eliminate duplicate code
workweekButtons.forEach(button => { */
const handler = (event: Event) => { private setupButtonGroup(selector: string, attribute: string, handler: (value: string) => void): void {
const buttons = document.querySelectorAll(selector);
buttons.forEach(button => {
const clickHandler = (event: Event) => {
event.preventDefault(); event.preventDefault();
const workweekId = button.getAttribute('data-workweek'); const value = button.getAttribute(attribute);
if (workweekId) { if (value) {
this.changeWorkweek(workweekId); handler(value);
} }
}; };
button.addEventListener('click', handler); button.addEventListener('click', clickHandler);
this.buttonListeners.set(button, handler); this.buttonListeners.set(button, clickHandler);
}); });
} }
/**
* Get cached view buttons with cache invalidation
*/
private getViewButtons(): NodeListOf<Element> {
const now = Date.now();
if (!this.cachedViewButtons || (now - this.lastButtonCacheTime) > this.CACHE_DURATION) {
this.cachedViewButtons = document.querySelectorAll('swp-view-button[data-view]');
this.lastButtonCacheTime = now;
}
return this.cachedViewButtons;
}
/**
* Get cached workweek buttons with cache invalidation
*/
private getWorkweekButtons(): NodeListOf<Element> {
const now = Date.now();
if (!this.cachedWorkweekButtons || (now - this.lastButtonCacheTime) > this.CACHE_DURATION) {
this.cachedWorkweekButtons = document.querySelectorAll('swp-preset-button[data-workweek]');
this.lastButtonCacheTime = now;
}
return this.cachedWorkweekButtons;
}
/**
* Initialize view with consolidated button updates
*/
private initializeView(): void { private initializeView(): void {
this.updateViewButtons(); this.updateAllButtons();
this.updateWorkweekButtons(); this.emitViewRendered();
this.eventBus.emit(CoreEvents.VIEW_RENDERED, {
view: this.currentView
});
} }
/**
* Optimized view change with debouncing
*/
private changeView(newView: CalendarView): void { private changeView(newView: CalendarView): void {
if (newView === this.currentView) return; if (newView === this.currentView) return;
const previousView = this.currentView; const previousView = this.currentView;
this.currentView = newView; this.currentView = newView;
this.updateAllButtons();
this.updateViewButtons();
this.eventBus.emit(CoreEvents.VIEW_CHANGED, { this.eventBus.emit(CoreEvents.VIEW_CHANGED, {
previousView, previousView,
@ -103,23 +140,44 @@ export class ViewManager {
}); });
} }
/**
* Optimized workweek change with consolidated updates
*/
private changeWorkweek(workweekId: string): void { private changeWorkweek(workweekId: string): void {
// Update the calendar config // Update the calendar config
calendarConfig.setWorkWeek(workweekId); calendarConfig.setWorkWeek(workweekId);
// Update button states // Update button states using cached elements
this.updateWorkweekButtons(); this.updateAllButtons();
// Trigger a calendar refresh to apply the new workweek // Trigger a calendar refresh to apply the new workweek
this.eventBus.emit(CoreEvents.REFRESH_REQUESTED); this.eventBus.emit(CoreEvents.REFRESH_REQUESTED);
} }
private updateViewButtons(): void { /**
const viewButtons = document.querySelectorAll('swp-view-button[data-view]'); * Consolidated button update method to eliminate duplicate code
viewButtons.forEach(button => { */
const buttonView = button.getAttribute('data-view') as CalendarView; private updateAllButtons(): void {
if (buttonView === this.currentView) { this.updateButtonGroup(
this.getViewButtons(),
'data-view',
this.currentView
);
this.updateButtonGroup(
this.getWorkweekButtons(),
'data-workweek',
calendarConfig.getCurrentWorkWeek()
);
}
/**
* Generic button group update to eliminate duplicate logic
*/
private updateButtonGroup(buttons: NodeListOf<Element>, attribute: string, activeValue: string): void {
buttons.forEach(button => {
const buttonValue = button.getAttribute(attribute);
if (buttonValue === activeValue) {
button.setAttribute('data-active', 'true'); button.setAttribute('data-active', 'true');
} else { } else {
button.removeAttribute('data-active'); button.removeAttribute('data-active');
@ -127,38 +185,46 @@ export class ViewManager {
}); });
} }
private updateWorkweekButtons(): void { /**
const currentWorkweek = calendarConfig.getCurrentWorkWeek(); * Emit view rendered event with current view
const workweekButtons = document.querySelectorAll('swp-preset-button[data-workweek]'); */
private emitViewRendered(): void {
workweekButtons.forEach(button => {
const buttonWorkweek = button.getAttribute('data-workweek');
if (buttonWorkweek === currentWorkweek) {
button.setAttribute('data-active', 'true');
} else {
button.removeAttribute('data-active');
}
});
}
private refreshCurrentView(): void {
this.eventBus.emit(CoreEvents.VIEW_RENDERED, { this.eventBus.emit(CoreEvents.VIEW_RENDERED, {
view: this.currentView view: this.currentView
}); });
} }
/**
* Refresh current view by emitting view rendered event
*/
private refreshCurrentView(): void {
this.emitViewRendered();
}
/**
* Validate if a string is a valid calendar view
*/
private isValidView(view: string): view is CalendarView { private isValidView(view: string): view is CalendarView {
return ['day', 'week', 'month'].includes(view); return ['day', 'week', 'month'].includes(view);
} }
/**
* Get current active view
*/
public getCurrentView(): CalendarView { public getCurrentView(): CalendarView {
return this.currentView; return this.currentView;
} }
/**
* Public refresh method
*/
public refresh(): void { public refresh(): void {
this.refreshCurrentView(); this.refreshCurrentView();
} }
/**
* Clean up all resources and cached elements
*/
public destroy(): void { public destroy(): void {
// Clean up event bus listeners // Clean up event bus listeners
this.eventCleanup.forEach(cleanup => cleanup()); this.eventCleanup.forEach(cleanup => cleanup());
@ -169,5 +235,10 @@ export class ViewManager {
button.removeEventListener('click', handler); button.removeEventListener('click', handler);
}); });
this.buttonListeners.clear(); this.buttonListeners.clear();
// Clear cached elements
this.cachedViewButtons = null;
this.cachedWorkweekButtons = null;
this.lastButtonCacheTime = 0;
} }
} }

View file

@ -1,94 +1,124 @@
import { CalendarConfig } from '../core/CalendarConfig'; import { CalendarConfig } from '../core/CalendarConfig';
import { ResourceCalendarData } from '../types/CalendarTypes'; import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory'; import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { HeaderRenderContext } from './HeaderRenderer'; import { HeaderRenderContext } from './HeaderRenderer';
import { ColumnRenderContext } from './ColumnRenderer'; import { ColumnRenderContext } from './ColumnRenderer';
import { eventBus } from '../core/EventBus'; import { eventBus } from '../core/EventBus';
import { DateCalculator } from '../utils/DateCalculator';
/** /**
* GridRenderer - Handles DOM rendering for the calendar grid * GridRenderer - Centralized DOM rendering for calendar grid
* Separated from GridManager to follow Single Responsibility Principle * Optimized to reduce redundant DOM operations and improve performance
*/ */
export class GridRenderer { export class GridRenderer {
private config: CalendarConfig; private config: CalendarConfig;
private headerEventListener: ((event: Event) => void) | null = null; private headerEventListener: ((event: Event) => void) | null = null;
private cachedGridContainer: HTMLElement | null = null;
private cachedCalendarHeader: HTMLElement | null = null;
private cachedTimeAxis: HTMLElement | null = null;
constructor(config: CalendarConfig) { constructor(config: CalendarConfig) {
this.config = config; this.config = config;
} }
/** /**
* Render the complete grid structure * Render the complete grid structure with view-aware optimization
*/ */
public renderGrid( public renderGrid(
grid: HTMLElement, grid: HTMLElement,
currentWeek: Date, currentDate: Date,
resourceData: ResourceCalendarData | null resourceData: ResourceCalendarData | null,
view: CalendarView = 'week'
): void { ): void {
if (!grid || !currentWeek) { if (!grid || !currentDate) {
return; return;
} }
// Cache grid reference for performance
this.cachedGridContainer = grid;
// Only clear and rebuild if grid is empty (first render) // Only clear and rebuild if grid is empty (first render)
if (grid.children.length === 0) { if (grid.children.length === 0) {
// Create POC structure: header-spacer + time-axis + grid-container this.createCompleteGridStructure(grid, currentDate, resourceData, view);
this.createHeaderSpacer(grid);
this.createTimeAxis(grid);
this.createGridContainer(grid, currentWeek, resourceData);
} else { } else {
// Just update the calendar header for all-day events // Optimized update - only refresh dynamic content
this.updateCalendarHeader(grid, currentWeek, resourceData); this.updateGridContent(grid, currentDate, resourceData, view);
} }
} }
/** /**
* Create header spacer to align time axis with week content * Create complete grid structure in one operation
*/ */
private createHeaderSpacer(grid: HTMLElement): void { private createCompleteGridStructure(
grid: HTMLElement,
currentDate: Date,
resourceData: ResourceCalendarData | null,
view: CalendarView
): void {
// Create all elements in memory first for better performance
const fragment = document.createDocumentFragment();
// Create header spacer
const headerSpacer = document.createElement('swp-header-spacer'); const headerSpacer = document.createElement('swp-header-spacer');
grid.appendChild(headerSpacer); fragment.appendChild(headerSpacer);
// Create time axis with caching
const timeAxis = this.createOptimizedTimeAxis();
this.cachedTimeAxis = timeAxis;
fragment.appendChild(timeAxis);
// Create grid container with caching
const gridContainer = this.createOptimizedGridContainer(currentDate, resourceData, view);
this.cachedGridContainer = gridContainer;
fragment.appendChild(gridContainer);
// Append all at once to minimize reflows
grid.appendChild(fragment);
} }
/** /**
* Create time axis (positioned beside grid container) * Create optimized time axis with caching
*/ */
private createTimeAxis(grid: HTMLElement): void { private createOptimizedTimeAxis(): HTMLElement {
const timeAxis = document.createElement('swp-time-axis'); const timeAxis = document.createElement('swp-time-axis');
const timeAxisContent = document.createElement('swp-time-axis-content'); const timeAxisContent = document.createElement('swp-time-axis-content');
const gridSettings = this.config.getGridSettings(); const gridSettings = this.config.getGridSettings();
const startHour = gridSettings.dayStartHour; const startHour = gridSettings.dayStartHour;
const endHour = gridSettings.dayEndHour; const endHour = gridSettings.dayEndHour;
// Create all hour markers in memory first
const fragment = document.createDocumentFragment();
for (let hour = startHour; hour < endHour; hour++) { for (let hour = startHour; hour < endHour; hour++) {
const marker = document.createElement('swp-hour-marker'); const marker = document.createElement('swp-hour-marker');
const period = hour >= 12 ? 'PM' : 'AM'; const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour); const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
marker.textContent = `${displayHour} ${period}`; marker.textContent = `${displayHour} ${period}`;
timeAxisContent.appendChild(marker); fragment.appendChild(marker);
} }
timeAxisContent.appendChild(fragment);
timeAxis.appendChild(timeAxisContent); timeAxis.appendChild(timeAxisContent);
grid.appendChild(timeAxis); return timeAxis;
} }
/** /**
* Create grid container with header and scrollable content * Create optimized grid container with header and scrollable content
*/ */
private createGridContainer( private createOptimizedGridContainer(
grid: HTMLElement, currentDate: Date,
currentWeek: Date, resourceData: ResourceCalendarData | null,
resourceData: ResourceCalendarData | null view: CalendarView
): void { ): HTMLElement {
const gridContainer = document.createElement('swp-grid-container'); const gridContainer = document.createElement('swp-grid-container');
// Create calendar header using Strategy Pattern // Create calendar header with caching
const calendarHeader = document.createElement('swp-calendar-header'); const calendarHeader = document.createElement('swp-calendar-header');
this.renderCalendarHeader(calendarHeader, currentWeek, resourceData); this.renderCalendarHeader(calendarHeader, currentDate, resourceData, view);
this.cachedCalendarHeader = calendarHeader;
gridContainer.appendChild(calendarHeader); gridContainer.appendChild(calendarHeader);
// Create scrollable content // Create scrollable content structure
const scrollableContent = document.createElement('swp-scrollable-content'); const scrollableContent = document.createElement('swp-scrollable-content');
const timeGrid = document.createElement('swp-time-grid'); const timeGrid = document.createElement('swp-time-grid');
@ -96,30 +126,31 @@ export class GridRenderer {
const gridLines = document.createElement('swp-grid-lines'); const gridLines = document.createElement('swp-grid-lines');
timeGrid.appendChild(gridLines); timeGrid.appendChild(gridLines);
// Create column container using Strategy Pattern // Create column container
const columnContainer = document.createElement('swp-day-columns'); const columnContainer = document.createElement('swp-day-columns');
this.renderColumnContainer(columnContainer, currentWeek, resourceData); this.renderColumnContainer(columnContainer, currentDate, resourceData, view);
timeGrid.appendChild(columnContainer); timeGrid.appendChild(columnContainer);
scrollableContent.appendChild(timeGrid); scrollableContent.appendChild(timeGrid);
gridContainer.appendChild(scrollableContent); gridContainer.appendChild(scrollableContent);
grid.appendChild(gridContainer); return gridContainer;
} }
/** /**
* Render calendar header using Strategy Pattern * Render calendar header with view awareness
*/ */
private renderCalendarHeader( private renderCalendarHeader(
calendarHeader: HTMLElement, calendarHeader: HTMLElement,
currentWeek: Date, currentDate: Date,
resourceData: ResourceCalendarData | null resourceData: ResourceCalendarData | null,
view: CalendarView
): void { ): void {
const calendarType = this.config.getCalendarMode(); const calendarType = this.config.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType); const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
const context: HeaderRenderContext = { const context: HeaderRenderContext = {
currentWeek: currentWeek, currentWeek: currentDate, // HeaderRenderer expects currentWeek property
config: this.config, config: this.config,
resourceData: resourceData resourceData: resourceData
}; };
@ -129,23 +160,24 @@ export class GridRenderer {
// Always ensure all-day containers exist for all days // Always ensure all-day containers exist for all days
headerRenderer.ensureAllDayContainers(calendarHeader); headerRenderer.ensureAllDayContainers(calendarHeader);
// Setup event listener for mouseover detection // Setup optimized event listener
this.setupHeaderEventListener(calendarHeader); this.setupOptimizedHeaderEventListener(calendarHeader);
} }
/** /**
* Render column container using Strategy Pattern * Render column container with view awareness
*/ */
private renderColumnContainer( private renderColumnContainer(
columnContainer: HTMLElement, columnContainer: HTMLElement,
currentWeek: Date, currentDate: Date,
resourceData: ResourceCalendarData | null resourceData: ResourceCalendarData | null,
view: CalendarView
): void { ): void {
const calendarType = this.config.getCalendarMode(); const calendarType = this.config.getCalendarMode();
const columnRenderer = CalendarTypeFactory.getColumnRenderer(calendarType); const columnRenderer = CalendarTypeFactory.getColumnRenderer(calendarType);
const context: ColumnRenderContext = { const context: ColumnRenderContext = {
currentWeek: currentWeek, currentWeek: currentDate, // ColumnRenderer expects currentWeek property
config: this.config, config: this.config,
resourceData: resourceData resourceData: resourceData
}; };
@ -154,37 +186,53 @@ export class GridRenderer {
} }
/** /**
* Update only the calendar header without rebuilding entire grid * Optimized update of grid content without full rebuild
*/ */
private updateCalendarHeader( private updateGridContent(
grid: HTMLElement, grid: HTMLElement,
currentWeek: Date, currentDate: Date,
resourceData: ResourceCalendarData | null resourceData: ResourceCalendarData | null,
view: CalendarView
): void { ): void {
const calendarHeader = grid.querySelector('swp-calendar-header'); // Use cached elements if available
if (!calendarHeader) return; const calendarHeader = this.cachedCalendarHeader || grid.querySelector('swp-calendar-header');
if (calendarHeader) {
// Clear existing content // Clear and re-render header content
calendarHeader.innerHTML = ''; calendarHeader.innerHTML = '';
this.renderCalendarHeader(calendarHeader as HTMLElement, currentDate, resourceData, view);
}
// Re-render headers using Strategy Pattern - this will also re-attach the event listener // Update column container if needed
this.renderCalendarHeader(calendarHeader as HTMLElement, currentWeek, resourceData); const columnContainer = grid.querySelector('swp-day-columns');
if (columnContainer) {
columnContainer.innerHTML = '';
this.renderColumnContainer(columnContainer as HTMLElement, currentDate, resourceData, view);
}
} }
/** /**
* Setup or re-setup event delegation listener on calendar header * Setup optimized event delegation listener with better performance
*/ */
private setupHeaderEventListener(calendarHeader: HTMLElement): void { private setupOptimizedHeaderEventListener(calendarHeader: HTMLElement): void {
// Remove existing listener if any (stored reference approach) // Remove existing listener if any
if (this.headerEventListener) { if (this.headerEventListener) {
calendarHeader.removeEventListener('mouseover', this.headerEventListener); calendarHeader.removeEventListener('mouseover', this.headerEventListener);
} }
// Create new listener function // Create optimized listener with throttling
let lastEmitTime = 0;
const throttleDelay = 16; // ~60fps
this.headerEventListener = (event) => { this.headerEventListener = (event) => {
const now = Date.now();
if (now - lastEmitTime < throttleDelay) {
return; // Throttle events for better performance
}
lastEmitTime = now;
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
// Check what was hovered - could be day-header OR all-day-container // Optimized element detection
const dayHeader = target.closest('swp-day-header'); const dayHeader = target.closest('swp-day-header');
const allDayContainer = target.closest('swp-allday-container'); const allDayContainer = target.closest('swp-allday-container');
@ -196,24 +244,22 @@ export class GridRenderer {
hoveredElement = dayHeader as HTMLElement; hoveredElement = dayHeader as HTMLElement;
targetDate = hoveredElement.dataset.date; targetDate = hoveredElement.dataset.date;
} else if (allDayContainer) { } else if (allDayContainer) {
// For all-day areas, we need to determine which day column we're over
hoveredElement = allDayContainer as HTMLElement; hoveredElement = allDayContainer as HTMLElement;
// Calculate which day we're hovering over based on mouse position // Optimized day calculation using cached header rect
const headerRect = calendarHeader.getBoundingClientRect(); const headerRect = calendarHeader.getBoundingClientRect();
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header'); const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const mouseX = (event as MouseEvent).clientX - headerRect.left; const mouseX = (event as MouseEvent).clientX - headerRect.left;
const dayWidth = headerRect.width / dayHeaders.length; const dayWidth = headerRect.width / dayHeaders.length;
const dayIndex = Math.floor(mouseX / dayWidth); const dayIndex = Math.max(0, Math.min(dayHeaders.length - 1, Math.floor(mouseX / dayWidth)));
const targetDayHeader = dayHeaders[dayIndex] as HTMLElement; const targetDayHeader = dayHeaders[dayIndex] as HTMLElement;
targetDate = targetDayHeader?.dataset.date; targetDate = targetDayHeader?.dataset.date;
} else { } else {
return; // No valid element found return;
} }
// Get header renderer once and cache
// Get the header renderer for addToAllDay functionality
const calendarType = this.config.getCalendarMode(); const calendarType = this.config.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType); const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
@ -225,7 +271,23 @@ export class GridRenderer {
} }
}; };
// Add the new listener // Add the optimized listener
calendarHeader.addEventListener('mouseover', this.headerEventListener); calendarHeader.addEventListener('mouseover', this.headerEventListener);
} }
/**
* Clean up cached elements and event listeners
*/
public destroy(): void {
// Clean up event listeners
if (this.headerEventListener && this.cachedCalendarHeader) {
this.cachedCalendarHeader.removeEventListener('mouseover', this.headerEventListener);
}
// Clear cached references
this.cachedGridContainer = null;
this.cachedCalendarHeader = null;
this.cachedTimeAxis = null;
this.headerEventListener = null;
}
} }