From 05bb074e9a5cf76485e54a8d334da3f31d558781 Mon Sep 17 00:00:00 2001 From: Janus Knudsen Date: Wed, 3 Sep 2025 18:51:19 +0200 Subject: [PATCH] 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. --- src/managers/EventManager.ts | 159 ++++++++++++--------- src/managers/GridManager.ts | 257 +++++++++++++++++++++++++--------- src/managers/ViewManager.ts | 213 ++++++++++++++++++---------- src/renderers/GridRenderer.ts | 208 +++++++++++++++++---------- 4 files changed, 566 insertions(+), 271 deletions(-) diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts index 7852d24..019094c 100644 --- a/src/managers/EventManager.ts +++ b/src/managers/EventManager.ts @@ -2,71 +2,65 @@ import { EventBus } from '../core/EventBus'; import { IEventBus, CalendarEvent, ResourceCalendarData } from '../types/CalendarTypes'; import { CoreEvents } from '../constants/CoreEvents'; import { calendarConfig } from '../core/CalendarConfig'; +import { DateCalculator } from '../utils/DateCalculator'; /** - * EventManager - Administrerer event lifecycle og CRUD operationer - * Håndterer mock data og event synchronization + * EventManager - Optimized event lifecycle and CRUD operations + * Handles data loading with improved performance and caching */ export class EventManager { private eventBus: IEventBus; private events: CalendarEvent[] = []; + private rawData: any = null; + private eventCache = new Map(); // Cache for period queries + private lastCacheKey: string = ''; constructor(eventBus: IEventBus) { 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 { - await this.loadMockData(); - - // Debug: Log first few events - if (this.events.length > 0) { - } - } - - private async loadMockData(): Promise { try { - const calendarType = calendarConfig.getCalendarMode(); - let jsonFile: string; - - - if (calendarType === 'resource') { - jsonFile = '/src/data/mock-resource-events.json'; - } else { - jsonFile = '/src/data/mock-events.json'; - } - - - const response = await fetch(jsonFile); - if (!response.ok) { - throw new Error(`Failed to load mock events: ${response.status}`); - } - - const data = await response.json(); - - // Store raw data for GridManager - this.rawData = data; - - // Process data for internal use - this.processCalendarData(calendarType, data); + await this.loadMockData(); + this.clearCache(); // Clear cache when new data is loaded } catch (error) { - this.events = []; // Fallback to empty array + console.error('Failed to load event data:', error); + this.events = []; + this.rawData = null; } } - private processCalendarData(calendarType: string, data: any): void { + /** + * Optimized mock data loading with better resource handling + */ + private async loadMockData(): Promise { + const calendarType = calendarConfig.getCalendarMode(); + const jsonFile = calendarType === 'resource' + ? '/src/data/mock-resource-events.json' + : '/src/data/mock-events.json'; + + const response = await fetch(jsonFile); + if (!response.ok) { + throw new Error(`Failed to load mock events: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // Store raw data and process in one operation + this.rawData = data; + this.events = this.processCalendarData(calendarType, data); + } + + /** + * Optimized data processing with better type safety + */ + private processCalendarData(calendarType: string, data: any): CalendarEvent[] { if (calendarType === 'resource') { const resourceData = data as ResourceCalendarData; - this.events = resourceData.resources.flatMap(resource => + return resourceData.resources.flatMap(resource => resource.events.map(event => ({ ...event, resourceName: resource.name, @@ -74,18 +68,24 @@ export class EventManager { resourceEmployeeId: resource.employeeId })) ); - } else { - this.events = data as CalendarEvent[]; } + + return data as CalendarEvent[]; } - private syncEvents(): void { - // Events are synced during initialization - // This method maintained for internal state management only + /** + * Clear event cache when data changes + */ + private clearCache(): void { + this.eventCache.clear(); + this.lastCacheKey = ''; } - public getEvents(): CalendarEvent[] { - return [...this.events]; + /** + * 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; } - private rawData: any = null; - - + /** + * Optimized event lookup with early return + */ public getEventById(id: string): CalendarEvent | undefined { + // Use find for better performance than filter + first 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[] { - 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 eventEnd = new Date(event.end); // Event overlaps period if it starts before period ends AND ends after period starts 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 { const newEvent: CalendarEvent = { ...event, - id: Date.now().toString() + id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` }; this.events.push(newEvent); - this.syncEvents(); + this.clearCache(); // Clear cache when data changes this.eventBus.emit(CoreEvents.EVENT_CREATED, { event: newEvent @@ -131,6 +151,9 @@ export class EventManager { return newEvent; } + /** + * Optimized event update with validation + */ public updateEvent(id: string, updates: Partial): CalendarEvent | null { const eventIndex = this.events.findIndex(event => event.id === id); if (eventIndex === -1) return null; @@ -138,7 +161,7 @@ export class EventManager { const updatedEvent = { ...this.events[eventIndex], ...updates }; this.events[eventIndex] = updatedEvent; - this.syncEvents(); + this.clearCache(); // Clear cache when data changes this.eventBus.emit(CoreEvents.EVENT_UPDATED, { event: updatedEvent @@ -147,6 +170,9 @@ export class EventManager { return updatedEvent; } + /** + * Optimized event deletion with better error handling + */ public deleteEvent(id: string): boolean { const eventIndex = this.events.findIndex(event => event.id === id); if (eventIndex === -1) return false; @@ -154,7 +180,7 @@ export class EventManager { const deletedEvent = this.events[eventIndex]; this.events.splice(eventIndex, 1); - this.syncEvents(); + this.clearCache(); // Clear cache when data changes this.eventBus.emit(CoreEvents.EVENT_DELETED, { event: deletedEvent @@ -163,12 +189,19 @@ export class EventManager { return true; } - public refresh(): void { - this.syncEvents(); + /** + * Refresh data by reloading from source + */ + public async refresh(): Promise { + await this.loadData(); } - + /** + * Clean up resources and clear caches + */ public destroy(): void { this.events = []; + this.rawData = null; + this.clearCache(); } } \ No newline at end of file diff --git a/src/managers/GridManager.ts b/src/managers/GridManager.ts index 9fbf1aa..3411f00 100644 --- a/src/managers/GridManager.ts +++ b/src/managers/GridManager.ts @@ -1,31 +1,29 @@ /** - * GridManager - Simplified grid manager using Strategy Pattern - * Now delegates view-specific logic to strategy implementations + * GridManager - Simplified grid manager using centralized GridRenderer + * Delegates DOM rendering to GridRenderer, focuses on coordination */ import { eventBus } from '../core/EventBus'; import { calendarConfig } from '../core/CalendarConfig'; import { CoreEvents } from '../constants/CoreEvents'; import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes'; -import { ViewStrategy, ViewContext } from '../strategies/ViewStrategy'; -import { WeekViewStrategy } from '../strategies/WeekViewStrategy'; -import { MonthViewStrategy } from '../strategies/MonthViewStrategy'; +import { GridRenderer } from '../renderers/GridRenderer'; +import { DateCalculator } from '../utils/DateCalculator'; /** - * Simplified GridManager focused on coordination, not implementation + * Simplified GridManager focused on coordination, delegates rendering to GridRenderer */ export class GridManager { private container: HTMLElement | null = null; private currentDate: Date = new Date(); private resourceData: ResourceCalendarData | null = null; - private currentStrategy: ViewStrategy; + private currentView: CalendarView = 'week'; + private gridRenderer: GridRenderer; private eventCleanup: (() => void)[] = []; constructor() { - - // Default to week view strategy - this.currentStrategy = new WeekViewStrategy(); - + // Initialize GridRenderer with config + this.gridRenderer = new GridRenderer(calendarConfig); this.init(); } @@ -40,11 +38,12 @@ export class GridManager { } private subscribeToEvents(): void { - // Listen for view changes to switch strategies + // Listen for view changes this.eventCleanup.push( eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => { 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 { - - // 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 + public switchView(view: CalendarView): void { + this.currentView = view; 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 { if (!this.container) { return; } + // Delegate to GridRenderer with current view context + this.gridRenderer.renderGrid( + this.container, + this.currentDate, + this.resourceData + ); - // Create context for strategy - const context: ViewContext = { - currentDate: this.currentDate, - container: this.container, - resourceData: this.resourceData - }; + // Calculate period range using DateCalculator + const periodRange = this.getPeriodRange(); - // Delegate to current strategy - this.currentStrategy.renderGrid(context); + // Get layout config based on current view + const layoutConfig = this.getLayoutConfig(); - // Get layout info from strategy - 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 + // Emit grid rendered event eventBus.emit(CoreEvents.GRID_RENDERED, { container: this.container, currentDate: this.currentDate, @@ -130,22 +107,48 @@ export class GridManager { layoutConfig: layoutConfig, columnCount: layoutConfig.columnCount }); - } /** - * Get current period label from strategy + * Get current period label using DateCalculator */ 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 { - 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; eventBus.emit(CoreEvents.PERIOD_CHANGED, { @@ -158,14 +161,29 @@ export class GridManager { } /** - * Navigate to previous period using strategy + * Navigate to previous period using DateCalculator */ 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; eventBus.emit(CoreEvents.PERIOD_CHANGED, { - direction: 'previous', + direction: 'previous', newDate: prevDate, periodLabel: this.getCurrentPeriodLabel() }); @@ -188,26 +206,137 @@ export class GridManager { } /** - * Get current view's display dates + * Get current view's display dates using DateCalculator */ 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 */ public destroy(): void { - // Clean up event listeners this.eventCleanup.forEach(cleanup => cleanup()); this.eventCleanup = []; - // Clean up current strategy - this.currentStrategy.destroy(); - // Clear references this.container = 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; + } } \ No newline at end of file diff --git a/src/managers/ViewManager.ts b/src/managers/ViewManager.ts index fa122e6..e242775 100644 --- a/src/managers/ViewManager.ts +++ b/src/managers/ViewManager.ts @@ -4,122 +4,180 @@ import { calendarConfig } from '../core/CalendarConfig'; import { CoreEvents } from '../constants/CoreEvents'; /** - * ViewManager - Håndterer skift mellem dag/uge/måned visninger - * Arbejder med custom tags fra POC design + * ViewManager - Optimized view switching with consolidated event handling + * Reduces redundant DOM queries and event listener setups */ export class ViewManager { private eventBus: IEventBus; private currentView: CalendarView = 'week'; private eventCleanup: (() => void)[] = []; private buttonListeners: Map = new Map(); + + // Cached DOM elements for performance + private cachedViewButtons: NodeListOf | null = null; + private cachedWorkweekButtons: NodeListOf | null = null; + private lastButtonCacheTime: number = 0; + private readonly CACHE_DURATION = 5000; // 5 seconds constructor(eventBus: IEventBus) { this.eventBus = eventBus; this.setupEventListeners(); } + /** + * Consolidated event listener setup with better organization + */ 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.eventBus.on(CoreEvents.INITIALIZED, () => { this.initializeView(); }) ); - this.eventCleanup.push( - this.eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => { - const customEvent = event as CustomEvent; - const { currentView } = customEvent.detail; - this.changeView(currentView); - }) - ); + // Remove redundant VIEW_CHANGED listener that causes circular calls + // changeView is called directly from button handlers this.eventCleanup.push( this.eventBus.on(CoreEvents.DATE_CHANGED, () => { 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]'); - viewButtons.forEach(button => { - const handler = (event: Event) => { - event.preventDefault(); - const view = button.getAttribute('data-view') as CalendarView; - if (view && this.isValidView(view)) { - this.changeView(view); - } - }; - button.addEventListener('click', handler); - this.buttonListeners.set(button, handler); + /** + * Consolidated button handler setup with shared logic + */ + private setupButtonHandlers(): void { + // Setup view buttons with consolidated handler + this.setupButtonGroup('swp-view-button[data-view]', 'data-view', (value) => { + if (this.isValidView(value)) { + this.changeView(value as CalendarView); + } + }); + + // 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]'); - workweekButtons.forEach(button => { - const handler = (event: Event) => { + /** + * Generic button group setup to eliminate duplicate code + */ + 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(); - const workweekId = button.getAttribute('data-workweek'); - if (workweekId) { - this.changeWorkweek(workweekId); + const value = button.getAttribute(attribute); + if (value) { + handler(value); } }; - button.addEventListener('click', handler); - this.buttonListeners.set(button, handler); + button.addEventListener('click', clickHandler); + this.buttonListeners.set(button, clickHandler); }); } + /** + * Get cached view buttons with cache invalidation + */ + private getViewButtons(): NodeListOf { + 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 { + 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 { - this.updateViewButtons(); - this.updateWorkweekButtons(); - - this.eventBus.emit(CoreEvents.VIEW_RENDERED, { - view: this.currentView - }); + this.updateAllButtons(); + this.emitViewRendered(); } + /** + * Optimized view change with debouncing + */ private changeView(newView: CalendarView): void { if (newView === this.currentView) return; const previousView = this.currentView; this.currentView = newView; - - this.updateViewButtons(); + this.updateAllButtons(); - this.eventBus.emit(CoreEvents.VIEW_CHANGED, { + this.eventBus.emit(CoreEvents.VIEW_CHANGED, { previousView, - currentView: newView + currentView: newView }); } + /** + * Optimized workweek change with consolidated updates + */ private changeWorkweek(workweekId: string): void { - // Update the calendar config calendarConfig.setWorkWeek(workweekId); - // Update button states - this.updateWorkweekButtons(); + // Update button states using cached elements + this.updateAllButtons(); // Trigger a calendar refresh to apply the new workweek this.eventBus.emit(CoreEvents.REFRESH_REQUESTED); } - private updateViewButtons(): void { - const viewButtons = document.querySelectorAll('swp-view-button[data-view]'); - viewButtons.forEach(button => { - const buttonView = button.getAttribute('data-view') as CalendarView; - if (buttonView === this.currentView) { + /** + * Consolidated button update method to eliminate duplicate code + */ + private updateAllButtons(): void { + 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, attribute: string, activeValue: string): void { + buttons.forEach(button => { + const buttonValue = button.getAttribute(attribute); + if (buttonValue === activeValue) { button.setAttribute('data-active', 'true'); } else { button.removeAttribute('data-active'); @@ -127,38 +185,46 @@ export class ViewManager { }); } - private updateWorkweekButtons(): void { - const currentWorkweek = calendarConfig.getCurrentWorkWeek(); - const workweekButtons = document.querySelectorAll('swp-preset-button[data-workweek]'); - - 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 { + /** + * Emit view rendered event with current view + */ + private emitViewRendered(): void { this.eventBus.emit(CoreEvents.VIEW_RENDERED, { 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 { return ['day', 'week', 'month'].includes(view); } + /** + * Get current active view + */ public getCurrentView(): CalendarView { return this.currentView; } + /** + * Public refresh method + */ public refresh(): void { this.refreshCurrentView(); } + /** + * Clean up all resources and cached elements + */ public destroy(): void { // Clean up event bus listeners this.eventCleanup.forEach(cleanup => cleanup()); @@ -169,5 +235,10 @@ export class ViewManager { button.removeEventListener('click', handler); }); this.buttonListeners.clear(); + + // Clear cached elements + this.cachedViewButtons = null; + this.cachedWorkweekButtons = null; + this.lastButtonCacheTime = 0; } } \ No newline at end of file diff --git a/src/renderers/GridRenderer.ts b/src/renderers/GridRenderer.ts index 5d4f71c..8d306df 100644 --- a/src/renderers/GridRenderer.ts +++ b/src/renderers/GridRenderer.ts @@ -1,94 +1,124 @@ import { CalendarConfig } from '../core/CalendarConfig'; -import { ResourceCalendarData } from '../types/CalendarTypes'; +import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes'; import { CalendarTypeFactory } from '../factories/CalendarTypeFactory'; import { HeaderRenderContext } from './HeaderRenderer'; import { ColumnRenderContext } from './ColumnRenderer'; import { eventBus } from '../core/EventBus'; +import { DateCalculator } from '../utils/DateCalculator'; + /** - * GridRenderer - Handles DOM rendering for the calendar grid - * Separated from GridManager to follow Single Responsibility Principle + * GridRenderer - Centralized DOM rendering for calendar grid + * Optimized to reduce redundant DOM operations and improve performance */ export class GridRenderer { private config: CalendarConfig; 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) { this.config = config; } /** - * Render the complete grid structure + * Render the complete grid structure with view-aware optimization */ public renderGrid( - grid: HTMLElement, - currentWeek: Date, - resourceData: ResourceCalendarData | null + grid: HTMLElement, + currentDate: Date, + resourceData: ResourceCalendarData | null, + view: CalendarView = 'week' ): void { - if (!grid || !currentWeek) { + if (!grid || !currentDate) { return; } + // Cache grid reference for performance + this.cachedGridContainer = grid; + // Only clear and rebuild if grid is empty (first render) if (grid.children.length === 0) { - // Create POC structure: header-spacer + time-axis + grid-container - this.createHeaderSpacer(grid); - this.createTimeAxis(grid); - this.createGridContainer(grid, currentWeek, resourceData); + this.createCompleteGridStructure(grid, currentDate, resourceData, view); } else { - // Just update the calendar header for all-day events - this.updateCalendarHeader(grid, currentWeek, resourceData); + // Optimized update - only refresh dynamic content + this.updateGridContent(grid, currentDate, resourceData, view); } + } + + /** + * Create complete grid structure in one operation + */ + 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 to align time axis with week content - */ - private createHeaderSpacer(grid: HTMLElement): void { + // Create 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 timeAxisContent = document.createElement('swp-time-axis-content'); const gridSettings = this.config.getGridSettings(); const startHour = gridSettings.dayStartHour; const endHour = gridSettings.dayEndHour; - + // Create all hour markers in memory first + const fragment = document.createDocumentFragment(); for (let hour = startHour; hour < endHour; hour++) { const marker = document.createElement('swp-hour-marker'); const period = hour >= 12 ? 'PM' : 'AM'; const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour); marker.textContent = `${displayHour} ${period}`; - timeAxisContent.appendChild(marker); + fragment.appendChild(marker); } + timeAxisContent.appendChild(fragment); 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( - grid: HTMLElement, - currentWeek: Date, - resourceData: ResourceCalendarData | null - ): void { + private createOptimizedGridContainer( + currentDate: Date, + resourceData: ResourceCalendarData | null, + view: CalendarView + ): HTMLElement { 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'); - this.renderCalendarHeader(calendarHeader, currentWeek, resourceData); + this.renderCalendarHeader(calendarHeader, currentDate, resourceData, view); + this.cachedCalendarHeader = calendarHeader; gridContainer.appendChild(calendarHeader); - // Create scrollable content + // Create scrollable content structure const scrollableContent = document.createElement('swp-scrollable-content'); const timeGrid = document.createElement('swp-time-grid'); @@ -96,30 +126,31 @@ export class GridRenderer { const gridLines = document.createElement('swp-grid-lines'); timeGrid.appendChild(gridLines); - // Create column container using Strategy Pattern + // Create column container const columnContainer = document.createElement('swp-day-columns'); - this.renderColumnContainer(columnContainer, currentWeek, resourceData); + this.renderColumnContainer(columnContainer, currentDate, resourceData, view); timeGrid.appendChild(columnContainer); scrollableContent.appendChild(timeGrid); gridContainer.appendChild(scrollableContent); - grid.appendChild(gridContainer); + return gridContainer; } /** - * Render calendar header using Strategy Pattern + * Render calendar header with view awareness */ private renderCalendarHeader( calendarHeader: HTMLElement, - currentWeek: Date, - resourceData: ResourceCalendarData | null + currentDate: Date, + resourceData: ResourceCalendarData | null, + view: CalendarView ): void { const calendarType = this.config.getCalendarMode(); const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType); const context: HeaderRenderContext = { - currentWeek: currentWeek, + currentWeek: currentDate, // HeaderRenderer expects currentWeek property config: this.config, resourceData: resourceData }; @@ -129,23 +160,24 @@ export class GridRenderer { // Always ensure all-day containers exist for all days headerRenderer.ensureAllDayContainers(calendarHeader); - // Setup event listener for mouseover detection - this.setupHeaderEventListener(calendarHeader); + // Setup optimized event listener + this.setupOptimizedHeaderEventListener(calendarHeader); } /** - * Render column container using Strategy Pattern + * Render column container with view awareness */ private renderColumnContainer( columnContainer: HTMLElement, - currentWeek: Date, - resourceData: ResourceCalendarData | null + currentDate: Date, + resourceData: ResourceCalendarData | null, + view: CalendarView ): void { const calendarType = this.config.getCalendarMode(); const columnRenderer = CalendarTypeFactory.getColumnRenderer(calendarType); const context: ColumnRenderContext = { - currentWeek: currentWeek, + currentWeek: currentDate, // ColumnRenderer expects currentWeek property config: this.config, 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, - currentWeek: Date, - resourceData: ResourceCalendarData | null + currentDate: Date, + resourceData: ResourceCalendarData | null, + view: CalendarView ): void { - const calendarHeader = grid.querySelector('swp-calendar-header'); - if (!calendarHeader) return; + // Use cached elements if available + const calendarHeader = this.cachedCalendarHeader || grid.querySelector('swp-calendar-header'); + if (calendarHeader) { + // Clear and re-render header content + calendarHeader.innerHTML = ''; + this.renderCalendarHeader(calendarHeader as HTMLElement, currentDate, resourceData, view); + } - // Clear existing content - calendarHeader.innerHTML = ''; - - // Re-render headers using Strategy Pattern - this will also re-attach the event listener - this.renderCalendarHeader(calendarHeader as HTMLElement, currentWeek, resourceData); + // Update column container if needed + 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 { - // Remove existing listener if any (stored reference approach) + private setupOptimizedHeaderEventListener(calendarHeader: HTMLElement): void { + // Remove existing listener if any if (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) => { + const now = Date.now(); + if (now - lastEmitTime < throttleDelay) { + return; // Throttle events for better performance + } + lastEmitTime = now; + 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 allDayContainer = target.closest('swp-allday-container'); @@ -196,36 +244,50 @@ export class GridRenderer { hoveredElement = dayHeader as HTMLElement; targetDate = hoveredElement.dataset.date; } else if (allDayContainer) { - // For all-day areas, we need to determine which day column we're over 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 dayHeaders = calendarHeader.querySelectorAll('swp-day-header'); const mouseX = (event as MouseEvent).clientX - headerRect.left; 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; targetDate = targetDayHeader?.dataset.date; } else { - return; // No valid element found + return; } - - // Get the header renderer for addToAllDay functionality + // Get header renderer once and cache const calendarType = this.config.getCalendarMode(); const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType); - eventBus.emit('header:mouseover', { + eventBus.emit('header:mouseover', { element: hoveredElement, targetDate, - headerRenderer + headerRenderer }); } }; - // Add the new listener + // Add the optimized listener 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; + } } \ No newline at end of file