Implements strategy pattern for calendar views

Refactors the grid management to use a strategy pattern, allowing for different calendar views (week, month, day) to be rendered using separate strategy implementations.

This approach improves code organization, reduces complexity within the main grid manager, and makes it easier to add new view types in the future.

The strategy pattern centralizes view-specific logic, improves testability, and reduces code duplication.

A month view strategy has been added and is now selectable via UI.
This commit is contained in:
Janus Knudsen 2025-08-20 19:52:18 +02:00
parent 3ddc6352f2
commit 414ef1caaf
6 changed files with 527 additions and 252 deletions

View file

@ -1,294 +1,256 @@
// Grid structure management - Simple CSS Grid Implementation with Strategy Pattern
/**
* GridManager - Simplified grid manager using Strategy Pattern
* Now delegates view-specific logic to strategy implementations
*/
import { eventBus } from '../core/EventBus';
import { calendarConfig } from '../core/CalendarConfig';
import { EventTypes } from '../constants/EventTypes';
import { DateCalculator } from '../utils/DateCalculator';
import { ResourceCalendarData } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes';
import { AllDayEvent } from '../types/EventTypes';
import { GridRenderer } from '../renderers/GridRenderer';
import { GridStyleManager } from '../renderers/GridStyleManager';
import { ViewStrategy, ViewContext } from '../strategies/ViewStrategy';
import { WeekViewStrategy } from '../strategies/WeekViewStrategy';
import { MonthViewStrategy } from '../strategies/MonthViewStrategy';
/**
* Grid position interface
*/
interface GridPosition {
minutes: number;
time: string;
y: number;
}
/**
* Manages the calendar grid structure using simple CSS Grid with Strategy Pattern
* Simplified GridManager focused on coordination, not implementation
*/
export class GridManager {
private container: HTMLElement | null = null;
private grid: HTMLElement | null = null;
private currentWeek: Date | null = null;
private allDayEvents: AllDayEvent[] = []; // Store all-day events for current week
private resourceData: ResourceCalendarData | null = null; // Store resource data for resource calendar
private gridRenderer: GridRenderer;
private styleManager: GridStyleManager;
private dateCalculator: DateCalculator;
private currentDate: Date = new Date();
private allDayEvents: AllDayEvent[] = [];
private resourceData: ResourceCalendarData | null = null;
private currentStrategy: ViewStrategy;
private eventCleanup: (() => void)[] = [];
constructor() {
console.log('🏗️ GridManager: Constructor called');
this.gridRenderer = new GridRenderer(calendarConfig);
this.styleManager = new GridStyleManager(calendarConfig);
this.dateCalculator = new DateCalculator(calendarConfig);
console.log('🏗️ GridManager: Constructor called with Strategy Pattern');
// Default to week view strategy
this.currentStrategy = new WeekViewStrategy();
this.init();
}
private init(): void {
this.findElements();
this.subscribeToEvents();
// Set initial current week to today if not set
if (!this.currentWeek) {
this.currentWeek = this.dateCalculator.getISOWeekStart(new Date());
console.log('GridManager: Set initial currentWeek to', this.currentWeek);
// Don't render immediately - wait for proper initialization event
console.log('GridManager: Waiting for initialization complete before rendering');
}
console.log('GridManager: Initialized with strategy pattern');
}
private getWeekStart(date: Date): Date {
// Use DateCalculator for ISO week (Monday = start)
return this.dateCalculator.getISOWeekStart(date);
}
private findElements(): void {
this.grid = document.querySelector('swp-calendar-container');
console.log('GridManager: findElements called, found swp-calendar-container:', !!this.grid);
this.container = document.querySelector('swp-calendar-container');
console.log('GridManager: Found container:', !!this.container);
}
private subscribeToEvents(): void {
// State-driven events removed - render() is now called directly by CalendarManager
// Re-render grid on config changes
eventBus.on(EventTypes.CONFIG_UPDATE, (e: Event) => {
const detail = (e as CustomEvent).detail;
if (['dayStartHour', 'dayEndHour', 'hourHeight', 'view', 'weekDays', 'fitToWidth'].includes(detail.key)) {
// Listen for view changes to switch strategies
this.eventCleanup.push(
eventBus.on(EventTypes.VIEW_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.switchViewStrategy(detail.currentView);
})
);
// Listen for data changes
this.eventCleanup.push(
eventBus.on(EventTypes.DATE_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentDate = detail.currentDate;
this.render();
}
});
// Re-render on calendar type change
eventBus.on(EventTypes.CALENDAR_TYPE_CHANGED, () => {
this.render();
});
// Re-render on view change
eventBus.on(EventTypes.VIEW_CHANGE, () => {
this.render();
});
// Re-render on period change
eventBus.on(EventTypes.PERIOD_CHANGE, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentWeek = this.dateCalculator.getISOWeekStart(detail.week);
this.render();
});
// Handle week changes from NavigationManager
eventBus.on(EventTypes.WEEK_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentWeek = this.dateCalculator.getISOWeekStart(detail.weekStart);
this.render();
});
// Handle date changes from CalendarManager
eventBus.on(EventTypes.DATE_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentWeek = this.dateCalculator.getISOWeekStart(detail.currentDate);
this.render();
});
// Handle events loaded
eventBus.on(EventTypes.EVENTS_LOADED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.updateAllDayEvents(detail.events);
});
// Handle grid clicks
this.setupGridInteractions();
})
);
this.eventCleanup.push(
eventBus.on(EventTypes.WEEK_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentDate = detail.weekStart;
this.render();
})
);
this.eventCleanup.push(
eventBus.on(EventTypes.EVENTS_LOADED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.updateAllDayEvents(detail.events);
})
);
// Listen for config changes that affect rendering
this.eventCleanup.push(
eventBus.on(EventTypes.CONFIG_UPDATE, (e: Event) => {
this.render();
})
);
this.eventCleanup.push(
eventBus.on(EventTypes.WORKWEEK_CHANGED, () => {
this.render();
})
);
}
/**
* Switch to a different view strategy
*/
public switchViewStrategy(view: CalendarView): void {
console.log(`GridManager: Switching to ${view} strategy`);
// 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:
console.warn(`GridManager: Unknown view type ${view}, defaulting to week`);
this.currentStrategy = new WeekViewStrategy();
}
// Re-render with new strategy
this.render();
}
/**
* Set resource data for resource calendar mode
*/
public setResourceData(resourceData: ResourceCalendarData | null): void {
this.resourceData = resourceData;
console.log('GridManager: Set resource data:', resourceData ? `${resourceData.resources.length} resources` : 'null');
console.log('GridManager: Updated resource data');
this.render();
}
/**
* Render the complete grid structure - now returns Promise for direct calls
* Main render method - delegates to current strategy
*/
async render(): Promise<void> {
if (!this.grid) {
console.warn('GridManager: render() called but this.grid is null, re-finding elements');
this.findElements();
if (!this.grid) {
throw new Error('GridManager: swp-calendar-container not found, cannot render');
}
public async render(): Promise<void> {
if (!this.container) {
console.warn('GridManager: No container found, cannot render');
return;
}
console.group(`🏗️ GRID RENDER: ${this.currentWeek?.toDateString()}`);
console.log('Updating grid styles and rendering structure...');
this.styleManager.updateGridStyles(this.resourceData);
this.gridRenderer.renderGrid(this.grid, this.currentWeek!, this.resourceData, this.allDayEvents);
console.group(`🎨 GRID RENDER: ${this.currentDate.toDateString()}`);
console.log(`Using strategy: ${this.currentStrategy.constructor.name}`);
const columnCount = this.styleManager.getColumnCount(this.resourceData);
console.log(`Grid structure complete - ${columnCount} columns created`);
// Create context for strategy
const context: ViewContext = {
currentDate: this.currentDate,
container: this.container,
allDayEvents: this.allDayEvents,
resourceData: this.resourceData
};
// Emit GRID_RENDERED event to trigger event rendering
const weekEnd = this.currentWeek ? new Date(this.currentWeek.getTime() + 6 * 24 * 60 * 60 * 1000) : null;
eventBus.emit(EventTypes.GRID_RENDERED, {
container: this.grid,
currentWeek: this.currentWeek,
startDate: this.currentWeek,
endDate: weekEnd,
columnCount: columnCount
// Delegate to current strategy
this.currentStrategy.renderGrid(context);
// Get layout info from strategy
const layoutConfig = this.currentStrategy.getLayoutConfig();
// Emit grid rendered event
eventBus.emit(CoreEvents.GRID_RENDERED, {
container: this.container,
currentDate: this.currentDate,
layoutConfig: layoutConfig,
columnCount: layoutConfig.columnCount
});
console.log(`Grid rendered with ${layoutConfig.columnCount} columns`);
console.groupEnd();
}
// Column count calculation moved to GridStyleManager
// Grid rendering methods moved to GridRenderer
/**
* Update all-day events data and re-render if needed
* Update all-day events and re-render
*/
private updateAllDayEvents(events: AllDayEvent[]): void {
if (!this.currentWeek) return;
// Filter all-day events for current week
const weekStart = this.currentWeek;
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
this.allDayEvents = events.filter(event => {
if (!event.allDay) return false;
const eventDate = new Date(event.start);
return eventDate >= weekStart && eventDate <= weekEnd;
});
console.log('GridManager: Updated all-day events:', this.allDayEvents.length);
// Update only the calendar header if grid is already rendered
if (this.grid && this.grid.children.length > 0) {
this.gridRenderer.renderGrid(this.grid, this.currentWeek!, this.resourceData, this.allDayEvents);
}
console.log(`GridManager: Updating ${events.length} all-day events`);
this.allDayEvents = events.filter(event => event.allDay);
this.render();
}
// CSS management methods moved to GridStyleManager
/**
* Setup grid interaction handlers for POC structure
*/
private setupGridInteractions(): void {
if (!this.grid) return;
// Click handler for day columns (works for both date and resource columns)
this.grid.addEventListener('click', (e: MouseEvent) => {
// Ignore if clicking on an event
if ((e.target as Element).closest('swp-event')) return;
const dayColumn = (e.target as Element).closest('swp-day-column, swp-resource-column') as HTMLElement;
if (!dayColumn) return;
const position = this.getClickPosition(e, dayColumn);
eventBus.emit(EventTypes.GRID_CLICK, {
date: (dayColumn as any).dataset.date,
resource: (dayColumn as any).dataset.resource,
employeeId: (dayColumn as any).dataset.employeeId,
time: position.time,
minutes: position.minutes
});
});
// Double click handler for day columns
this.grid.addEventListener('dblclick', (e: MouseEvent) => {
// Ignore if clicking on an event
if ((e.target as Element).closest('swp-event')) return;
const dayColumn = (e.target as Element).closest('swp-day-column, swp-resource-column') as HTMLElement;
if (!dayColumn) return;
const position = this.getClickPosition(e, dayColumn);
eventBus.emit(EventTypes.GRID_DBLCLICK, {
date: (dayColumn as any).dataset.date,
resource: (dayColumn as any).dataset.resource,
employeeId: (dayColumn as any).dataset.employeeId,
time: position.time,
minutes: position.minutes
});
});
}
/**
* Get click position in day column (POC structure)
*/
private getClickPosition(event: MouseEvent, dayColumn: HTMLElement): GridPosition {
const rect = dayColumn.getBoundingClientRect();
const y = event.clientY - rect.top;
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const minuteHeight = hourHeight / 60;
const snapInterval = gridSettings.snapInterval;
const dayStartHour = gridSettings.dayStartHour;
// Calculate total minutes from day start
let totalMinutes = Math.floor(y / minuteHeight);
// Snap to interval
totalMinutes = Math.round(totalMinutes / snapInterval) * snapInterval;
// Add day start offset
totalMinutes += dayStartHour * 60;
return {
minutes: totalMinutes,
time: this.minutesToTime(totalMinutes),
y: y
};
}
/**
* Scroll to specific hour
*/
scrollToHour(hour: number): void {
if (!this.grid) return;
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const headerHeight = 80; // Header row height
const scrollTop = headerHeight + ((hour - dayStartHour) * hourHeight);
this.grid.scrollTop = scrollTop;
}
/**
* Utility methods
*/
private minutesToTime(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const period = hours >= 12 ? 'PM' : 'AM';
const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
/**
* Get current period label from strategy
*/
public getCurrentPeriodLabel(): string {
return this.currentStrategy.getPeriodLabel(this.currentDate);
}
/**
* Navigate to next period using strategy
*/
public navigateNext(): void {
const nextDate = this.currentStrategy.getNextPeriod(this.currentDate);
this.currentDate = nextDate;
return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
eventBus.emit(CoreEvents.PERIOD_CHANGED, {
direction: 'next',
newDate: nextDate,
periodLabel: this.getCurrentPeriodLabel()
});
this.render();
}
/**
* Navigate to previous period using strategy
*/
public navigatePrevious(): void {
const prevDate = this.currentStrategy.getPreviousPeriod(this.currentDate);
this.currentDate = prevDate;
eventBus.emit(CoreEvents.PERIOD_CHANGED, {
direction: 'previous',
newDate: prevDate,
periodLabel: this.getCurrentPeriodLabel()
});
this.render();
}
/**
* Navigate to today
*/
public navigateToToday(): void {
this.currentDate = new Date();
eventBus.emit(CoreEvents.DATE_CHANGED, {
newDate: this.currentDate,
periodLabel: this.getCurrentPeriodLabel()
});
this.render();
}
/**
* Get current view's display dates
*/
public getDisplayDates(): Date[] {
return this.currentStrategy.getDisplayDates(this.currentDate);
}
/**
* Clean up all resources
*/
public destroy(): void {
console.log('GridManager: Cleaning up');
// Clean up event listeners
this.eventCleanup.forEach(cleanup => cleanup());
this.eventCleanup = [];
// Clean up current strategy
this.currentStrategy.destroy();
// Clear references
this.container = null;
this.allDayEvents = [];
this.resourceData = null;
}
}