From c07d83d86ffd0b975b650f15ea65e3f8d504d4a4 Mon Sep 17 00:00:00 2001 From: Janus Knudsen Date: Fri, 12 Sep 2025 00:36:02 +0200 Subject: [PATCH] Refactors calendar event rendering and management Improves code organization and maintainability by separating concerns related to all-day event rendering, header management, and event resizing. Moves all-day event rendering logic into a dedicated `AllDayEventRenderer` class, utilizing the factory pattern for event element creation. Refactors `AllDayManager` to handle all-day row height animations, separated from `HeaderManager`. Removes the `ResizeManager` and related functionality. These changes aim to reduce code duplication, improve testability, and enhance the overall architecture of the calendar component. --- docs/code-improvement-plan.md | 183 ++++++++++++ src/managers/AllDayManager.ts | 220 ++++++++++++++ src/managers/HeaderManager.ts | 28 +- src/managers/ResizeManager.ts | 264 ----------------- src/renderers/AllDayEventRenderer.ts | 174 +++++++++++ src/renderers/EventRenderer.ts | 423 +-------------------------- src/renderers/GridRenderer.ts | 2 - src/renderers/HeaderRenderer.ts | 237 +-------------- src/renderers/NavigationRenderer.ts | 3 - wwwroot/css/calendar-base-css.css | 16 +- wwwroot/css/calendar-events-css.css | 8 +- wwwroot/css/calendar-layout-css.css | 2 +- wwwroot/css/calendar.css | 345 ---------------------- 13 files changed, 599 insertions(+), 1306 deletions(-) create mode 100644 docs/code-improvement-plan.md create mode 100644 src/managers/AllDayManager.ts delete mode 100644 src/managers/ResizeManager.ts create mode 100644 src/renderers/AllDayEventRenderer.ts delete mode 100644 wwwroot/css/calendar.css diff --git a/docs/code-improvement-plan.md b/docs/code-improvement-plan.md new file mode 100644 index 0000000..33901f0 --- /dev/null +++ b/docs/code-improvement-plan.md @@ -0,0 +1,183 @@ +# Kodeanalyse og Forbedringsplan - Calendar System + +## Overordnet Vurdering +Koden er generelt velstruktureret med god separation of concerns. Der er dog stadig nogle områder med duplikering og potentiale for yderligere optimering. + +## Positive Observationer ✅ + +### 1. God Arkitektur +- **Factory Pattern**: SwpEventElement bruger factory pattern korrekt +- **Event-driven**: Konsistent brug af EventBus for kommunikation +- **Caching**: God brug af caching i DragDropManager og EventManager +- **Separation**: AllDayManager er korrekt separeret fra HeaderManager + +### 2. Performance Optimering +- **DOM Caching**: DragDropManager cacher DOM elementer effektivt +- **Event Throttling**: Implementeret i flere managers +- **Lazy Loading**: Smart brug af lazy loading patterns + +### 3. TypeScript Best Practices +- Stærk typing med interfaces +- God brug af branded types (EventId) +- Konsistent error handling + +## Identificerede Problemer og Forbedringsforslag 🔧 + +### 1. Duplikeret Time Formatting +**Problem**: `formatTime()` metode findes i: +- EventRenderer.ts (linje 280-297) +- SwpEventElement.ts (linje 44-50) + +**Løsning**: Opret en central TimeFormatter utility: +```typescript +// src/utils/TimeFormatter.ts +export class TimeFormatter { + static formatTime(input: number | Date | string): string { + // Centraliseret implementation + } +} +``` + +### 2. Duplikeret Cache Management +**Problem**: Lignende cache patterns i: +- AllDayManager (linje 11-76) +- HeaderManager +- GridRenderer + +**Løsning**: Generisk CacheManager: +```typescript +// src/utils/CacheManager.ts +export class DOMCacheManager> { + private cache: T; + + constructor(initialCache: T) { + this.cache = initialCache; + } + + get(key: K, selector?: string): T[K] { + if (!this.cache[key] && selector) { + this.cache[key] = document.querySelector(selector) as T[K]; + } + return this.cache[key]; + } + + clear(): void { + Object.keys(this.cache).forEach(key => { + this.cache[key as keyof T] = null; + }); + } +} +``` + +### 3. Overlap Detection Kompleksitet +**Problem**: EventRenderer har stadig "new_" prefixed metoder som indikerer ufærdig refactoring + +**Løsning**: +- Fjern "new_" prefix fra metoderne +- Flyt al overlap logik til OverlapDetector +- Simplificer EventRenderer + +### 4. Grid Positioning Beregninger +**Problem**: Grid position beregninger gentages flere steder + +**Løsning**: Centralisér i GridPositionCalculator: +```typescript +// src/utils/GridPositionCalculator.ts +export class GridPositionCalculator { + static calculateEventPosition(event: CalendarEvent): { top: number; height: number } + static calculateSnapPosition(y: number, snapInterval: number): number + static pixelsToMinutes(pixels: number, hourHeight: number): number + static minutesToPixels(minutes: number, hourHeight: number): number +} +``` + +### 5. Event Element Creation +**Problem**: SwpEventElement kunne forenkles yderligere + +**Forslag**: +- Tilføj flere factory metoder for forskellige event typer +- Implementer builder pattern for komplekse events + +### 6. All-Day Event Row Calculation +**Problem**: AllDayManager har kompleks row calculation logik (linje 108-143) + +**Løsning**: Udtræk til separat utility: +```typescript +// src/utils/AllDayRowCalculator.ts +export class AllDayRowCalculator { + static calculateRequiredRows(events: HTMLElement[]): number + static expandEventsByDate(events: HTMLElement[]): Record +} +``` + +### 7. Manglende Unit Tests +**Problem**: Ingen test filer fundet + +**Løsning**: Tilføj tests for kritiske utilities: +- TimeFormatter +- GridPositionCalculator +- OverlapDetector +- AllDayRowCalculator + +## Prioriteret Handlingsplan + +### Fase 1: Utilities (Høj Prioritet) +1. ✅ SwpEventElement factory (allerede implementeret) +2. ⬜ TimeFormatter utility +3. ⬜ DOMCacheManager +4. ⬜ GridPositionCalculator + +### Fase 2: Refactoring (Medium Prioritet) +5. ⬜ Fjern "new_" prefix fra EventRenderer metoder +6. ⬜ Simplificer AllDayManager med AllDayRowCalculator +7. ⬜ Konsolider overlap detection + +### Fase 3: Testing & Dokumentation (Lav Prioritet) +8. ⬜ Unit tests for utilities +9. ⬜ JSDoc dokumentation +10. ⬜ Performance benchmarks + +## Arkitektur Diagram + +```mermaid +graph TD + A[Utilities Layer] --> B[TimeFormatter] + A --> C[DOMCacheManager] + A --> D[GridPositionCalculator] + A --> E[AllDayRowCalculator] + + F[Managers] --> A + G[Renderers] --> A + H[Elements] --> A + + F --> I[EventManager] + F --> J[DragDropManager] + F --> K[AllDayManager] + + G --> L[EventRenderer] + G --> M[AllDayEventRenderer] + + H --> N[SwpEventElement] + H --> O[SwpAllDayEventElement] +``` + +## Performance Forbedringer + +### 1. Event Delegation +Overvej at bruge event delegation i stedet for individuelle event listeners på hver event element. + +### 2. Virtual Scrolling +For kalendere med mange events, implementer virtual scrolling. + +### 3. Web Workers +Overvej at flytte tunge beregninger til Web Workers. + +## Konklusion + +Koden er generelt i god stand med solid arkitektur. De foreslåede forbedringer vil: +- Reducere code duplication med 30-40% +- Forbedre maintainability +- Gøre koden mere testbar +- Forbedre performance marginalt + +Estimeret tid for implementering: 2-3 dage for alle forbedringer. \ No newline at end of file diff --git a/src/managers/AllDayManager.ts b/src/managers/AllDayManager.ts new file mode 100644 index 0000000..a128a4e --- /dev/null +++ b/src/managers/AllDayManager.ts @@ -0,0 +1,220 @@ +// All-day row height management and animations + +import { eventBus } from '../core/EventBus'; +import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; + +/** + * AllDayManager - Handles all-day row height animations and management + * Separated from HeaderManager for clean responsibility separation + */ +export class AllDayManager { + private cachedAllDayContainer: HTMLElement | null = null; + private cachedCalendarHeader: HTMLElement | null = null; + private cachedHeaderSpacer: HTMLElement | null = null; + + constructor() { + // Bind methods for event listeners + this.checkAndAnimateAllDayHeight = this.checkAndAnimateAllDayHeight.bind(this); + } + + /** + * Get cached all-day container element + */ + private getAllDayContainer(): HTMLElement | null { + if (!this.cachedAllDayContainer) { + const calendarHeader = this.getCalendarHeader(); + if (calendarHeader) { + this.cachedAllDayContainer = calendarHeader.querySelector('swp-allday-container'); + } + } + return this.cachedAllDayContainer; + } + + /** + * Get cached calendar header element + */ + private getCalendarHeader(): HTMLElement | null { + if (!this.cachedCalendarHeader) { + this.cachedCalendarHeader = document.querySelector('swp-calendar-header'); + } + return this.cachedCalendarHeader; + } + + /** + * Get cached header spacer element + */ + private getHeaderSpacer(): HTMLElement | null { + if (!this.cachedHeaderSpacer) { + this.cachedHeaderSpacer = document.querySelector('swp-header-spacer'); + } + return this.cachedHeaderSpacer; + } + + /** + * Calculate all-day height based on number of rows + */ + private calculateAllDayHeight(targetRows: number): { + targetHeight: number; + currentHeight: number; + heightDifference: number; + } { + const root = document.documentElement; + const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT; + const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0'); + const heightDifference = targetHeight - currentHeight; + + return { targetHeight, currentHeight, heightDifference }; + } + + /** + * Clear cached DOM elements (call when DOM structure changes) + */ + private clearCache(): void { + this.cachedCalendarHeader = null; + this.cachedAllDayContainer = null; + this.cachedHeaderSpacer = null; + } + + /** + * Expand all-day row to show events + */ + public expandAllDayRow(): void { + const { currentHeight } = this.calculateAllDayHeight(0); + + if (currentHeight === 0) { + this.checkAndAnimateAllDayHeight(); + } + } + + /** + * Collapse all-day row when no events + */ + public collapseAllDayRow(): void { + this.animateToRows(0); + } + + /** + * Check current all-day events and animate to correct height + */ + public checkAndAnimateAllDayHeight(): void { + const container = this.getAllDayContainer(); + if (!container) return; + + const allDayEvents = container.querySelectorAll('swp-allday-event'); + + // Calculate required rows - 0 if no events (will collapse) + let maxRows = 0; + + if (allDayEvents.length > 0) { + // Expand events to all dates they span and group by date + const expandedEventsByDate: Record = {}; + + (Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => { + const startISO = event.dataset.start || ''; + const endISO = event.dataset.end || startISO; + const eventId = event.dataset.eventId || ''; + + // Extract dates from ISO strings + const startDate = startISO.split('T')[0]; // YYYY-MM-DD + const endDate = endISO.split('T')[0]; // YYYY-MM-DD + + // Loop through all dates from start to end + let current = new Date(startDate); + const end = new Date(endDate); + + while (current <= end) { + const dateStr = current.toISOString().split('T')[0]; // YYYY-MM-DD format + + if (!expandedEventsByDate[dateStr]) { + expandedEventsByDate[dateStr] = []; + } + expandedEventsByDate[dateStr].push(eventId); + + // Move to next day + current.setDate(current.getDate() + 1); + } + }); + + // Find max rows needed + maxRows = Math.max( + ...Object.values(expandedEventsByDate).map(ids => ids?.length || 0), + 0 + ); + } + + // Animate to required rows (0 = collapse, >0 = expand) + this.animateToRows(maxRows); + } + + /** + * Animate all-day container to specific number of rows + */ + public animateToRows(targetRows: number): void { + const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows); + + if (targetHeight === currentHeight) return; // No animation needed + + console.log(`🎬 All-day height animation: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`); + + // Get cached elements + const calendarHeader = this.getCalendarHeader(); + const headerSpacer = this.getHeaderSpacer(); + const allDayContainer = this.getAllDayContainer(); + + if (!calendarHeader || !allDayContainer) return; + + // Get current parent height for animation + const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height); + const targetParentHeight = currentParentHeight + heightDifference; + + const animations = [ + calendarHeader.animate([ + { height: `${currentParentHeight}px` }, + { height: `${targetParentHeight}px` } + ], { + duration: 300, + easing: 'ease-out', + fill: 'forwards' + }) + ]; + + // Add spacer animation if spacer exists + if (headerSpacer) { + const root = document.documentElement; + const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight; + const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight; + + animations.push( + headerSpacer.animate([ + { height: `${currentSpacerHeight}px` }, + { height: `${targetSpacerHeight}px` } + ], { + duration: 300, + easing: 'ease-out', + fill: 'forwards' + }) + ); + } + + // Update CSS variable after animation + Promise.all(animations.map(anim => anim.finished)).then(() => { + const root = document.documentElement; + root.style.setProperty('--all-day-row-height', `${targetHeight}px`); + eventBus.emit('header:height-changed'); + }); + } + + /** + * Update row height when all-day events change + */ + public updateRowHeight(): void { + this.checkAndAnimateAllDayHeight(); + } + + /** + * Clean up cached elements and resources + */ + public destroy(): void { + this.clearCache(); + } +} \ No newline at end of file diff --git a/src/managers/HeaderManager.ts b/src/managers/HeaderManager.ts index 928edd1..a89457f 100644 --- a/src/managers/HeaderManager.ts +++ b/src/managers/HeaderManager.ts @@ -50,32 +50,12 @@ export class HeaderManager { const target = event.target as HTMLElement; - // Optimized element detection + // Optimized element detection - only handle day headers const dayHeader = target.closest('swp-day-header'); - const allDayContainer = target.closest('swp-allday-container'); - if (dayHeader || allDayContainer) { - let hoveredElement: HTMLElement; - let targetDate: string | undefined; - - if (dayHeader) { - hoveredElement = dayHeader as HTMLElement; - targetDate = hoveredElement.dataset.date; - } else if (allDayContainer) { - hoveredElement = allDayContainer as HTMLElement; - - // 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.max(0, Math.min(dayHeaders.length - 1, Math.floor(mouseX / dayWidth))); - - const targetDayHeader = dayHeaders[dayIndex] as HTMLElement; - targetDate = targetDayHeader?.dataset.date; - } else { - return; - } + if (dayHeader) { + const hoveredElement = dayHeader as HTMLElement; + const targetDate = hoveredElement.dataset.date; // Get header renderer for coordination const calendarType = calendarConfig.getCalendarMode(); diff --git a/src/managers/ResizeManager.ts b/src/managers/ResizeManager.ts deleted file mode 100644 index 027f7c5..0000000 --- a/src/managers/ResizeManager.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { calendarConfig } from '../core/CalendarConfig'; -import { eventBus } from '../core/EventBus'; -import { IEventBus } from '../types/CalendarTypes'; - -/** - * Resize state interface - */ -interface ResizeState { - element: HTMLElement; - handle: 'top' | 'bottom'; - startY: number; - originalTop: number; - originalHeight: number; - originalStartTime: Date; - originalEndTime: Date; - minHeightPx: number; -} - -/** - * ResizeManager - Handles event resizing functionality - */ -export class ResizeManager { - private resizeState: ResizeState | null = null; - private readonly MIN_EVENT_DURATION_MINUTES = 15; - - constructor(private eventBus: IEventBus) { - // Bind methods for event listeners - this.handleResize = this.handleResize.bind(this); - this.endResize = this.endResize.bind(this); - } - - /** - * Setup dynamic resize handles that are only created when needed - * @param eventElement - Event element to add resize handles to - */ - public setupResizeHandles(eventElement: HTMLElement): void { - // Variables to track resize handles - let topHandle: HTMLElement | null = null; - let bottomHandle: HTMLElement | null = null; - - console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId); - - // Create resize handles on first mouseover - eventElement.addEventListener('mouseenter', () => { - if (!topHandle && !bottomHandle) { - topHandle = document.createElement('swp-resize-handle'); - topHandle.className = 'swp-resize-handle swp-resize-top'; - - bottomHandle = document.createElement('swp-resize-handle'); - bottomHandle.className = 'swp-resize-handle swp-resize-bottom'; - - // Add mousedown listeners for resize functionality - topHandle.addEventListener('mousedown', (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - this.startResize(eventElement, 'top', e); - }); - - bottomHandle.addEventListener('mousedown', (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - this.startResize(eventElement, 'bottom', e); - }); - - eventElement.appendChild(topHandle); - eventElement.appendChild(bottomHandle); - console.log('Created resize handles for event:', eventElement.dataset.eventId); - } - }); - - // Show/hide handles based on mouse position - eventElement.addEventListener('mousemove', (e: MouseEvent) => { - if (!topHandle || !bottomHandle) return; - - const rect = eventElement.getBoundingClientRect(); - const mouseY = e.clientY - rect.top; - const eventHeight = rect.height; - const topZone = eventHeight * 0.2; - const bottomZone = eventHeight * 0.8; - - // Show top handle in upper 20% - if (mouseY < topZone) { - topHandle.style.opacity = '1'; - bottomHandle.style.opacity = '0'; - } - // Show bottom handle in lower 20% - else if (mouseY > bottomZone) { - topHandle.style.opacity = '0'; - bottomHandle.style.opacity = '1'; - } - // Hide both if mouse is in middle - else { - topHandle.style.opacity = '0'; - bottomHandle.style.opacity = '0'; - } - }); - - // Hide handles when mouse leaves event (but only if not in resize mode) - eventElement.addEventListener('mouseleave', () => { - console.log('Mouse LEAVE event:', eventElement.dataset.eventId); - if (!this.resizeState && topHandle && bottomHandle) { - topHandle.style.opacity = '0'; - bottomHandle.style.opacity = '0'; - console.log('Hidden resize handles for event:', eventElement.dataset.eventId); - } - }); - } - - /** - * Start resize operation - */ - private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void { - const gridSettings = calendarConfig.getGridSettings(); - const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight; - - this.resizeState = { - element: eventElement, - handle: handle, - startY: e.clientY, - originalTop: parseFloat(eventElement.style.top), - originalHeight: parseFloat(eventElement.style.height), - originalStartTime: new Date(eventElement.dataset.start || ''), - originalEndTime: new Date(eventElement.dataset.end || ''), - minHeightPx: minHeightPx - }; - - // Global listeners for resize - document.addEventListener('mousemove', this.handleResize); - document.addEventListener('mouseup', this.endResize); - - // Add resize cursor to body - document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize'; - - console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId); - } - - /** - * Handle resize drag - */ - private handleResize(e: MouseEvent): void { - if (!this.resizeState) return; - - const deltaY = e.clientY - this.resizeState.startY; - const snappedDelta = this.snapToGrid(deltaY); - const gridSettings = calendarConfig.getGridSettings(); - - if (this.resizeState.handle === 'top') { - // Resize from top - const newTop = this.resizeState.originalTop + snappedDelta; - const newHeight = this.resizeState.originalHeight - snappedDelta; - - // Check minimum height - if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) { - this.resizeState.element.style.top = newTop + 'px'; - this.resizeState.element.style.height = newHeight + 'px'; - - // Update times - const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; - const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta); - this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime); - } - } else { - // Resize from bottom - const newHeight = this.resizeState.originalHeight + snappedDelta; - - // Check minimum height - if (newHeight >= this.resizeState.minHeightPx) { - this.resizeState.element.style.height = newHeight + 'px'; - - // Update times - const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; - const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta); - this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime); - } - } - } - - /** - * End resize operation - */ - private endResize(): void { - if (!this.resizeState) return; - - // Get final times from element - const finalStart = this.resizeState.element.dataset.start; - const finalEnd = this.resizeState.element.dataset.end; - - console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd); - - // Emit event with new times - this.eventBus.emit('event:resized', { - eventId: this.resizeState.element.dataset.eventId, - newStart: finalStart, - newEnd: finalEnd - }); - - // Cleanup - document.removeEventListener('mousemove', this.handleResize); - document.removeEventListener('mouseup', this.endResize); - document.body.style.cursor = ''; - this.resizeState = null; - } - - /** - * Snap delta to grid intervals - */ - private snapToGrid(deltaY: number): number { - const gridSettings = calendarConfig.getGridSettings(); - const snapInterval = gridSettings.snapInterval; - const hourHeight = gridSettings.hourHeight; - const snapDistancePx = (snapInterval / 60) * hourHeight; - return Math.round(deltaY / snapDistancePx) * snapDistancePx; - } - - /** - * Update event display during resize - */ - private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void { - // Calculate new duration in minutes - const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60); - - // Update dataset - element.dataset.start = startTime.toISOString(); - element.dataset.end = endTime.toISOString(); - element.dataset.duration = durationMinutes.toString(); - - // Update visual time - const timeElement = element.querySelector('swp-event-time'); - if (timeElement) { - const startStr = this.formatTime(startTime.toISOString()); - const endStr = this.formatTime(endTime.toISOString()); - timeElement.textContent = `${startStr} - ${endStr}`; - } - } - - /** - * Add minutes to a date - */ - private addMinutes(date: Date, minutes: number): Date { - return new Date(date.getTime() + minutes * 60000); - } - - /** - * Format time for display - */ - private formatTime(input: Date | string): string { - let hours: number; - let minutes: number; - - if (input instanceof Date) { - hours = input.getHours(); - minutes = input.getMinutes(); - } else { - // Date or ISO string input - const date = typeof input === 'string' ? new Date(input) : input; - hours = date.getHours(); - minutes = date.getMinutes(); - } - - const period = hours >= 12 ? 'PM' : 'AM'; - const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours); - return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; - } -} \ No newline at end of file diff --git a/src/renderers/AllDayEventRenderer.ts b/src/renderers/AllDayEventRenderer.ts new file mode 100644 index 0000000..48837de --- /dev/null +++ b/src/renderers/AllDayEventRenderer.ts @@ -0,0 +1,174 @@ +// All-day event rendering using factory pattern + +import { CalendarEvent } from '../types/CalendarTypes'; +import { SwpAllDayEventElement } from '../elements/SwpEventElement'; +import { DateCalculator } from '../utils/DateCalculator'; + +/** + * AllDayEventRenderer - Handles rendering of all-day events in header row + * Uses factory pattern with SwpAllDayEventElement for clean DOM creation + */ +export class AllDayEventRenderer { + + /** + * Render all-day events in the header container + */ + public renderAllDayEvents(events: CalendarEvent[], container: HTMLElement): void { + const allDayEvents = events.filter(event => event.allDay); + + // Find the calendar header + const calendarHeader = container.querySelector('swp-calendar-header'); + if (!calendarHeader) { + return; + } + + // Find or create all-day container + let allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement; + if (!allDayContainer) { + allDayContainer = document.createElement('swp-allday-container'); + calendarHeader.appendChild(allDayContainer); + } + + // Clear existing events + allDayContainer.innerHTML = ''; + + if (allDayEvents.length === 0) { + return; + } + + // Build date to column mapping + const dayHeaders = calendarHeader.querySelectorAll('swp-day-header'); + const dateToColumnMap = new Map(); + + dayHeaders.forEach((header, index) => { + const dateStr = (header as HTMLElement).dataset.date; + if (dateStr) { + dateToColumnMap.set(dateStr, index + 1); + } + }); + + // Calculate grid positioning for events + const eventPlacements = this.calculateEventPlacements(allDayEvents, dateToColumnMap); + + // Render events using factory pattern + eventPlacements.forEach(({ event, gridColumn, gridRow }) => { + const eventDateStr = DateCalculator.formatISODate(event.start); + const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr); + const allDayElement = swpAllDayEvent.getElement(); + + // Apply grid positioning + (allDayElement as HTMLElement).style.gridColumn = gridColumn; + (allDayElement as HTMLElement).style.gridRow = gridRow.toString(); + + // Use event metadata for color if available + if (event.metadata?.color) { + (allDayElement as HTMLElement).style.backgroundColor = event.metadata.color; + } + + allDayContainer.appendChild(allDayElement); + }); + } + + /** + * Calculate grid positioning for all-day events with overlap detection + */ + private calculateEventPlacements(events: CalendarEvent[], dateToColumnMap: Map) { + // Calculate spans for each event + const eventItems = events.map(event => { + const eventDateStr = DateCalculator.formatISODate(event.start); + const endDateStr = DateCalculator.formatISODate(event.end); + + const startColumn = dateToColumnMap.get(eventDateStr); + const endColumn = dateToColumnMap.get(endDateStr); + + if (startColumn === undefined) { + return null; + } + + const columnSpan = endColumn !== undefined && endColumn >= startColumn + ? endColumn - startColumn + 1 + : 1; + + return { + event, + span: { + startColumn: startColumn, + columnSpan: columnSpan + } + }; + }).filter(item => item !== null) as Array<{ + event: CalendarEvent; + span: { startColumn: number; columnSpan: number }; + }>; + + // Calculate row placement to avoid overlaps + interface EventPlacement { + event: CalendarEvent; + gridColumn: string; + gridRow: number; + } + + const eventPlacements: EventPlacement[] = []; + + eventItems.forEach(eventItem => { + let assignedRow = 1; + + // Find first available row + while (true) { + // Check if this row has any conflicts + const rowEvents = eventPlacements.filter(p => p.gridRow === assignedRow); + + const hasOverlap = rowEvents.some(rowEvent => { + // Parse the existing grid column to check overlap + const existingSpan = this.parseGridColumn(rowEvent.gridColumn); + return this.spansOverlap(eventItem.span, existingSpan); + }); + + if (!hasOverlap) { + break; // Found available row + } + assignedRow++; + } + + const gridColumn = eventItem.span.columnSpan > 1 + ? `${eventItem.span.startColumn} / span ${eventItem.span.columnSpan}` + : `${eventItem.span.startColumn}`; + + eventPlacements.push({ + event: eventItem.event, + gridColumn, + gridRow: assignedRow + }); + }); + + return eventPlacements; + } + + /** + * Check if two column spans overlap + */ + private spansOverlap(span1: { startColumn: number; columnSpan: number }, span2: { startColumn: number; columnSpan: number }): boolean { + const span1End = span1.startColumn + span1.columnSpan - 1; + const span2End = span2.startColumn + span2.columnSpan - 1; + + return !(span1End < span2.startColumn || span2End < span1.startColumn); + } + + /** + * Parse grid column string back to span object + */ + private parseGridColumn(gridColumn: string): { startColumn: number; columnSpan: number } { + if (gridColumn.includes('span')) { + const parts = gridColumn.split(' / span '); + return { + startColumn: parseInt(parts[0]), + columnSpan: parseInt(parts[1]) + }; + } else { + return { + startColumn: parseInt(gridColumn), + columnSpan: 1 + }; + } + } +} \ No newline at end of file diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index 72f05c3..129854b 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -6,7 +6,6 @@ import { DateCalculator } from '../utils/DateCalculator'; import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector'; -import { ResizeManager } from '../managers/ResizeManager'; import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement'; /** @@ -28,14 +27,12 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { private originalEvent: HTMLElement | null = null; // Resize manager - private resizeManager: ResizeManager; constructor(dateCalculator?: DateCalculator) { if (!dateCalculator) { DateCalculator.initialize(calendarConfig); } this.dateCalculator = dateCalculator || new DateCalculator(); - this.resizeManager = new ResizeManager(eventBus); } // ============================================ @@ -135,40 +132,13 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { this.handleColumnChange(eventId, newColumn); }); - // Handle convert to all-day - eventBus.on('drag:convert-to-allday', (event) => { - const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail; - this.handleConvertToAllDay(eventId, targetDate, headerRenderer); - }); - - // Handle convert to timed event - eventBus.on('drag:convert-to-timed', (event) => { - const { eventId, targetColumn, targetY } = (event as CustomEvent).detail; - this.handleConvertToTimed(eventId, targetColumn, targetY); - }); - - // Handle all-day to timed conversion (when leaving header) - eventBus.on('drag:convert-allday-to-timed', (event) => { - const { eventId, originalElement } = (event as CustomEvent).detail; - this.handleConvertAllDayToTimed(eventId, originalElement); - }); // Handle navigation period change (when slide animation completes) eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => { // Animate all-day height after navigation completes - this.triggerAllDayHeightAnimation(); }); } - /** - * Trigger all-day height animation without creating new renderer instance - */ - private triggerAllDayHeightAnimation(): void { - import('./HeaderRenderer').then(({ DateHeaderRenderer }) => { - const headerRenderer = new DateHeaderRenderer(); - headerRenderer.checkAndAnimateAllDayHeight(); - }); - } /** * Cleanup method for proper resource management @@ -688,250 +658,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { /** * Handle conversion to all-day event */ - private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void { - if (!this.draggedClone) return; - - // Only convert once - if (this.draggedClone.dataset.displayType === 'allday') return; - - // Transform clone to all-day format - this.transformCloneToAllDay(this.draggedClone, targetDate); - - // Expand header if needed - headerRenderer.addToAllDay(this.draggedClone.parentElement); - - } - - /** - * Transform clone from timed to all-day event by modifying existing element - */ - private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void { - const calendarHeader = document.querySelector('swp-calendar-header'); - if (!calendarHeader) return; - - // Find all-day container - const allDayContainer = calendarHeader.querySelector('swp-allday-container'); - if (!allDayContainer) return; - - // Extract event data for transformation - const titleElement = clone.querySelector('swp-event-title'); - const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled'; - - const timeElement = clone.querySelector('swp-event-time'); - const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : ''; - - // Calculate column index for CSS Grid positioning - const dayHeaders = document.querySelectorAll('swp-day-header'); - let columnIndex = 1; - dayHeaders.forEach((header, index) => { - if ((header as HTMLElement).dataset.date === targetDate) { - columnIndex = index + 1; - } - }); - - // Transform the existing element in-place instead of creating new one - // Update dataset for all-day format - clone.dataset.displayType = "allday"; - clone.dataset.allDay = "true"; - clone.dataset.start = `${targetDate}T00:00:00`; - clone.dataset.end = `${targetDate}T23:59:59`; - if (eventDuration) { - clone.dataset.duration = eventDuration; - } - - // Change content to all-day format (just title) - clone.innerHTML = eventTitle; - - // Clear timed event positioning - clone.style.position = ''; - clone.style.top = ''; - clone.style.height = ''; - clone.style.left = ''; - clone.style.right = ''; - - // Apply CSS grid positioning for all-day - clone.style.gridColumn = columnIndex.toString(); - - // Move element to all-day container - const parent = clone.parentElement; - if (parent) { - parent.removeChild(clone); - } - allDayContainer.appendChild(clone); - - // draggedClone reference stays the same since it's the same element - - // Check if height animation is needed - this.triggerAllDayHeightAnimation(); - } - - /** - * Handle conversion from all-day to timed event - */ - private handleConvertToTimed(eventId: string, targetColumn: string, targetY: number): void { - if (!this.draggedClone) return; - - // Only convert if it's an all-day event - if (this.draggedClone.dataset.displayType !== 'allday') return; - - // Transform clone to timed format - this.transformAllDayToTimed(this.draggedClone, targetColumn, targetY); - } - - /** - * Handle all-day to timed conversion by transforming existing element - */ - private handleConvertAllDayToTimed(eventId: string, originalElement: HTMLElement): void { - if (!this.draggedClone) return; - - // Only convert if it's an all-day event - if (this.draggedClone.dataset.displayType !== 'allday') return; - - // Transform the existing element instead of creating a new one - this.transformAllDayToTimedInPlace(this.draggedClone); - } - - /** - * Transform all-day element to timed by modifying existing element in place - */ - private transformAllDayToTimedInPlace(allDayElement: HTMLElement): void { - // Extract event data - const eventId = allDayElement.dataset.eventId || ''; - const eventTitle = allDayElement.dataset.title || allDayElement.textContent || 'Untitled'; - const eventType = allDayElement.dataset.type || 'work'; - const duration = parseInt(allDayElement.dataset.duration || '60'); - - // Calculate position for timed event (use current time or 9 AM default) - const now = new Date(); - const startHour = now.getHours() || 9; - const startMinutes = now.getMinutes() || 0; - - // Transform the existing element in-place instead of creating new one - // Update dataset for timed format - allDayElement.dataset.displayType = "timed"; - delete allDayElement.dataset.allDay; - - // Set timed event structure - const startTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes)); - const endTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes + duration)); - - allDayElement.innerHTML = ` - ${startTime} - ${endTime} - ${eventTitle} - `; - - // Clear all-day positioning - allDayElement.style.gridColumn = ''; - - // Apply timed event positioning - allDayElement.style.position = 'absolute'; - allDayElement.style.left = '2px'; - allDayElement.style.right = '2px'; - allDayElement.style.top = '100px'; // Default position, will be adjusted by drag system - allDayElement.style.height = '57px'; // Default height for 1 hour - - // Find a day column to place the element (try to use today's column) - const columns = document.querySelectorAll('swp-day-column'); - let targetColumn = columns[0]; // fallback - - const today = new Date().toISOString().split('T')[0]; - columns.forEach(col => { - if ((col as HTMLElement).dataset.date === today) { - targetColumn = col; - } - }); - - const eventsLayer = targetColumn?.querySelector('swp-events-layer'); - - // Move element from all-day container to events layer - const parent = allDayElement.parentElement; - if (parent) { - parent.removeChild(allDayElement); - } - - // Add to events layer - if (eventsLayer) { - eventsLayer.appendChild(allDayElement); - } - - // draggedClone reference stays the same since it's the same element - } - - /** - * Transform clone from all-day to timed event - */ - private transformAllDayToTimed(allDayClone: HTMLElement, targetColumn: string, targetY: number): void { - // Find target column element - const columnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`); - if (!columnElement) return; - - const eventsLayer = columnElement.querySelector('swp-events-layer'); - if (!eventsLayer) return; - - // Extract event data from all-day element - const eventId = allDayClone.dataset.eventId || ''; - const eventTitle = allDayClone.dataset.title || allDayClone.textContent || 'Untitled'; - const eventType = allDayClone.dataset.type || 'work'; - - // Calculate time from Y position - const gridSettings = calendarConfig.getGridSettings(); - const hourHeight = gridSettings.hourHeight; - const dayStartHour = gridSettings.dayStartHour; - const snapInterval = gridSettings.snapInterval; - - // Calculate start time from position - const minutesFromGridStart = (targetY / hourHeight) * 60; - const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; - const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval; - - // Use default duration or extract from dataset - const duration = parseInt(allDayClone.dataset.duration || '60'); - const endMinutes = snappedStartMinutes + duration; - - // Create dates with target column date - const columnDate = new Date(targetColumn + 'T00:00:00'); - const startDate = new Date(columnDate); - startDate.setMinutes(snappedStartMinutes); - - const endDate = new Date(columnDate); - endDate.setMinutes(endMinutes); - - // Create CalendarEvent object for helper methods - const tempEvent: CalendarEvent = { - id: eventId, - title: eventTitle, - start: startDate, - end: endDate, - type: eventType, - allDay: false, - syncStatus: 'synced', - metadata: { - duration: duration - } - }; - - // Create timed event using factory - const swpTimedEvent = SwpEventElement.fromCalendarEvent(tempEvent); - const timedEvent = swpTimedEvent.getElement(); - - // Set additional drag-specific attributes - timedEvent.dataset.originalDuration = duration.toString(); - - // Apply drag styling and positioning - this.applyDragStyling(timedEvent); - const eventHeight = (duration / 60) * hourHeight - 3; - timedEvent.style.height = `${eventHeight}px`; - timedEvent.style.top = `${targetY}px`; - - // Remove all-day element - allDayClone.remove(); - - // Add timed event to events layer - eventsLayer.appendChild(timedEvent); - - // Update reference - this.draggedClone = timedEvent; - } /** * Fade out and remove element @@ -953,19 +679,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // clearEvents() would remove events from all containers, breaking the animation // Events are now rendered directly into the new container without clearing - // Separate all-day events from regular events - const allDayEvents = events.filter(event => event.allDay); - const regularEvents = events.filter(event => !event.allDay); + // Only handle regular (non-all-day) events - // Always call renderAllDayEvents to ensure height is set correctly (even to 0) - this.renderAllDayEvents(allDayEvents, container); // Find columns in the specific container for regular events const columns = this.getColumns(container); columns.forEach(column => { - const columnEvents = this.getEventsForColumn(column, regularEvents); + const columnEvents = this.getEventsForColumn(column, events); const eventsLayer = column.querySelector('swp-events-layer'); if (eventsLayer) { @@ -979,101 +701,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { protected abstract getColumns(container: HTMLElement): HTMLElement[]; protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[]; - /** - * Render all-day events in the header row 2 - */ - protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement): void { - - // Find the calendar header - const calendarHeader = container.querySelector('swp-calendar-header'); - if (!calendarHeader) { - return; - } - - // Find the all-day container (should always exist now) - const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement; - if (!allDayContainer) { - console.warn('All-day container not found - this should not happen'); - return; - } - - // Clear existing events - allDayContainer.innerHTML = ''; - - if (allDayEvents.length === 0) { - // No events - container exists but is empty and hidden - return; - } - - // Build date to column mapping - const dayHeaders = calendarHeader.querySelectorAll('swp-day-header'); - const dateToColumnMap = new Map(); - - dayHeaders.forEach((header, index) => { - const dateStr = (header as any).dataset.date; - if (dateStr) { - dateToColumnMap.set(dateStr, index + 1); // 1-based column index - } - }); - - // Calculate grid spans for all events - const eventSpans = allDayEvents.map(event => ({ - event, - span: this.calculateEventGridSpan(event, dateToColumnMap) - })).filter(item => item.span.columnSpan > 0); // Remove events outside visible range - - // Simple row assignment using overlap detection - const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = []; - - eventSpans.forEach(eventItem => { - let assignedRow = 1; - - // Find first row where this event doesn't overlap with any existing event - while (true) { - const rowEvents = eventPlacements.filter(item => item.row === assignedRow); - const hasOverlap = rowEvents.some(rowEvent => - this.spansOverlap(eventItem.span, rowEvent.span) - ); - - if (!hasOverlap) { - break; // Found available row - } - assignedRow++; - } - - eventPlacements.push({ - event: eventItem.event, - span: eventItem.span, - row: assignedRow - }); - }); - - // Get max row needed - const maxRow = Math.max(...eventPlacements.map(item => item.row), 1); - - // Place events directly in the single container - eventPlacements.forEach(({ event, span, row }) => { - // Create all-day event using factory - const eventDateStr = DateCalculator.formatISODate(event.start); - const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr); - const allDayEvent = swpAllDayEvent.getElement(); - - // Override grid position for spanning events - (allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1 - ? `${span.startColumn} / span ${span.columnSpan}` - : `${span.startColumn}`; - (allDayEvent as HTMLElement).style.gridRow = row.toString(); - - // Use event metadata for color if available - if (event.metadata?.color) { - (allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color; - } - - allDayContainer.appendChild(allDayEvent); - - }); - - } protected renderEvent(event: CalendarEvent): HTMLElement { const swpEvent = SwpEventElement.fromCalendarEvent(event); @@ -1082,7 +709,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // Setup resize handles on first mouseover only eventElement.addEventListener('mouseover', () => { if (eventElement.dataset.hasResizeHandlers !== 'true') { - this.resizeManager.setupResizeHandles(eventElement); eventElement.dataset.hasResizeHandlers = 'true'; } }, { once: true }); @@ -1113,51 +739,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { return { top, height }; } - /** - * Calculate grid column span for event - */ - private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map): { startColumn: number, columnSpan: number } { - const startDateKey = DateCalculator.formatISODate(event.start); - const startColumn = dateToColumnMap.get(startDateKey); - - if (!startColumn) { - return { startColumn: 0, columnSpan: 0 }; // Event outside visible range - } - - // Calculate span by checking each day - let endColumn = startColumn; - const currentDate = new Date(event.start); - - while (currentDate <= event.end) { - currentDate.setDate(currentDate.getDate() + 1); - const dateKey = DateCalculator.formatISODate(currentDate); - const col = dateToColumnMap.get(dateKey); - if (col) { - endColumn = col; - } else { - break; // Event extends beyond visible range - } - } - - const columnSpan = endColumn - startColumn + 1; - return { startColumn, columnSpan }; - } - - /** - * Check if two column spans overlap (for all-day events) - */ - private spansOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean { - const event1End = event1Span.startColumn + event1Span.columnSpan - 1; - const event2End = event2Span.startColumn + event2Span.columnSpan - 1; - - return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn); - } - - - - - - clearEvents(container?: HTMLElement): void { const selector = 'swp-event, swp-event-group'; const existingEvents = container diff --git a/src/renderers/GridRenderer.ts b/src/renderers/GridRenderer.ts index 3a8c38a..8813bef 100644 --- a/src/renderers/GridRenderer.ts +++ b/src/renderers/GridRenderer.ts @@ -154,8 +154,6 @@ export class GridRenderer { headerRenderer.render(calendarHeader, context); - // Always ensure all-day containers exist for all days - headerRenderer.ensureAllDayContainers(calendarHeader); // Setup only grid-related event listeners this.setupGridEventListeners(); diff --git a/src/renderers/HeaderRenderer.ts b/src/renderers/HeaderRenderer.ts index 54dc452..de5fa71 100644 --- a/src/renderers/HeaderRenderer.ts +++ b/src/renderers/HeaderRenderer.ts @@ -1,7 +1,6 @@ // Header rendering strategy interface and implementations -import { CalendarConfig, ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; -import { eventBus } from '../core/EventBus'; +import { CalendarConfig } from '../core/CalendarConfig'; import { ResourceCalendarData } from '../types/CalendarTypes'; import { DateCalculator } from '../utils/DateCalculator'; @@ -10,232 +9,8 @@ import { DateCalculator } from '../utils/DateCalculator'; */ export interface HeaderRenderer { render(calendarHeader: HTMLElement, context: HeaderRenderContext): void; - addToAllDay(dayHeader: HTMLElement): void; - ensureAllDayContainers(calendarHeader: HTMLElement): void; - checkAndAnimateAllDayHeight(): void; } -/** - * Base class with shared addToAllDay implementation - */ -export abstract class BaseHeaderRenderer implements HeaderRenderer { - // Cached DOM elements to avoid redundant queries - private cachedCalendarHeader: HTMLElement | null = null; - private cachedAllDayContainer: HTMLElement | null = null; - private cachedHeaderSpacer: HTMLElement | null = null; - - abstract render(calendarHeader: HTMLElement, context: HeaderRenderContext): void; - - /** - * Get cached calendar header element - */ - private getCalendarHeader(): HTMLElement | null { - if (!this.cachedCalendarHeader) { - this.cachedCalendarHeader = document.querySelector('swp-calendar-header'); - } - return this.cachedCalendarHeader; - } - - /** - * Get cached all-day container element - */ - private getAllDayContainer(): HTMLElement | null { - if (!this.cachedAllDayContainer) { - const calendarHeader = this.getCalendarHeader(); - if (calendarHeader) { - this.cachedAllDayContainer = calendarHeader.querySelector('swp-allday-container'); - } - } - return this.cachedAllDayContainer; - } - - /** - * Get cached header spacer element - */ - private getHeaderSpacer(): HTMLElement | null { - if (!this.cachedHeaderSpacer) { - this.cachedHeaderSpacer = document.querySelector('swp-header-spacer'); - } - return this.cachedHeaderSpacer; - } - - /** - * Calculate all-day height based on number of rows - */ - private calculateAllDayHeight(targetRows: number): { - targetHeight: number; - currentHeight: number; - heightDifference: number; - } { - const root = document.documentElement; - const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT; - const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0'); - const heightDifference = targetHeight - currentHeight; - - return { targetHeight, currentHeight, heightDifference }; - } - - /** - * Clear cached DOM elements (call when DOM structure changes) - */ - private clearCache(): void { - this.cachedCalendarHeader = null; - this.cachedAllDayContainer = null; - this.cachedHeaderSpacer = null; - } - - /** - * Expand header to show all-day row - */ - addToAllDay(dayHeader: HTMLElement): void { - const { currentHeight } = this.calculateAllDayHeight(0); - - if (currentHeight === 0) { - // Find the calendar header element to animate - const calendarHeader = dayHeader.closest('swp-calendar-header') as HTMLElement; - if (calendarHeader) { - // Ensure container exists BEFORE animation - this.createAllDayMainStructure(calendarHeader); - this.checkAndAnimateAllDayHeight(); - } - } - } - - /** - * Ensure all-day containers exist - always create them during header rendering - */ - ensureAllDayContainers(calendarHeader: HTMLElement): void { - this.createAllDayMainStructure(calendarHeader); - } - - checkAndAnimateAllDayHeight(): void { - const container = this.getAllDayContainer(); - if (!container) return; - - const allDayEvents = container.querySelectorAll('swp-allday-event'); - - // Calculate required rows - 0 if no events (will collapse) - let maxRows = 0; - - if (allDayEvents.length > 0) { - // Expand events to all dates they span and group by date - const expandedEventsByDate: Record = {}; - - (Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => { - const startISO = event.dataset.start || ''; - const endISO = event.dataset.end || startISO; - const eventId = event.dataset.eventId || ''; - - // Extract dates from ISO strings - const startDate = startISO.split('T')[0]; // YYYY-MM-DD - const endDate = endISO.split('T')[0]; // YYYY-MM-DD - - // Loop through all dates from start to end - let current = new Date(startDate); - const end = new Date(endDate); - - while (current <= end) { - const dateStr = current.toISOString().split('T')[0]; // YYYY-MM-DD format - - if (!expandedEventsByDate[dateStr]) { - expandedEventsByDate[dateStr] = []; - } - expandedEventsByDate[dateStr].push(eventId); - - // Move to next day - current.setDate(current.getDate() + 1); - } - }); - - // Find max rows needed - maxRows = Math.max( - ...Object.values(expandedEventsByDate).map(ids => ids?.length || 0), - 0 - ); - } - - // Animate to required rows (0 = collapse, >0 = expand) - this.animateToRows(maxRows); - } - - /** - * Animate all-day container to specific number of rows - */ - animateToRows(targetRows: number): void { - const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows); - - if (targetHeight === currentHeight) return; // No animation needed - - console.log(`🎬 All-day height animation starting: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`); - - // Get cached elements - const calendarHeader = this.getCalendarHeader(); - const headerSpacer = this.getHeaderSpacer(); - const allDayContainer = this.getAllDayContainer(); - - if (!calendarHeader || !allDayContainer) return; - - // Get current parent height for animation - const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height); - const targetParentHeight = currentParentHeight + heightDifference; - - const animations = [ - calendarHeader.animate([ - { height: `${currentParentHeight}px` }, - { height: `${targetParentHeight}px` } - ], { - duration: 300, - easing: 'ease-out', - fill: 'forwards' - }) - ]; - - // Add spacer animation if spacer exists - if (headerSpacer) { - const root = document.documentElement; - const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight; - const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight; - - animations.push( - headerSpacer.animate([ - { height: `${currentSpacerHeight}px` }, - { height: `${targetSpacerHeight}px` } - ], { - duration: 300, - easing: 'ease-out', - fill: 'forwards' - }) - ); - } - - // Update CSS variable after animation - Promise.all(animations.map(anim => anim.finished)).then(() => { - const root = document.documentElement; - root.style.setProperty('--all-day-row-height', `${targetHeight}px`); - eventBus.emit('header:height-changed'); - }); - } - - private createAllDayMainStructure(calendarHeader: HTMLElement): void { - // Check if container already exists - let container = calendarHeader.querySelector('swp-allday-container'); - - if (!container) { - // Create simple all-day container (initially hidden) - container = document.createElement('swp-allday-container'); - calendarHeader.appendChild(container); - // Clear cache since DOM structure changed - this.clearCache(); - } - } - - /** - * Public cleanup method for cached elements - */ - public destroy(): void { - this.clearCache(); - } -} /** * Context for header rendering @@ -249,7 +24,7 @@ export interface HeaderRenderContext { /** * Date-based header renderer (original functionality) */ -export class DateHeaderRenderer extends BaseHeaderRenderer { +export class DateHeaderRenderer implements HeaderRenderer { private dateCalculator!: DateCalculator; render(calendarHeader: HTMLElement, context: HeaderRenderContext): void { @@ -279,16 +54,13 @@ export class DateHeaderRenderer extends BaseHeaderRenderer { calendarHeader.appendChild(header); }); - - // Always create all-day container after rendering headers - this.ensureAllDayContainers(calendarHeader); } } /** * Resource-based header renderer */ -export class ResourceHeaderRenderer extends BaseHeaderRenderer { +export class ResourceHeaderRenderer implements HeaderRenderer { render(calendarHeader: HTMLElement, context: HeaderRenderContext): void { const { resourceData } = context; @@ -310,8 +82,5 @@ export class ResourceHeaderRenderer extends BaseHeaderRenderer { calendarHeader.appendChild(header); }); - - // Always create all-day container after rendering headers - this.ensureAllDayContainers(calendarHeader); } } \ No newline at end of file diff --git a/src/renderers/NavigationRenderer.ts b/src/renderers/NavigationRenderer.ts index 1af5bfe..6ca0efa 100644 --- a/src/renderers/NavigationRenderer.ts +++ b/src/renderers/NavigationRenderer.ts @@ -193,9 +193,6 @@ export class NavigationRenderer { header.appendChild(headerElement); }); - // Always ensure all-day containers exist for all days - const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarConfig.getCalendarMode()); - headerRenderer.ensureAllDayContainers(header as HTMLElement); // Render day columns for target week dates.forEach(date => { diff --git a/wwwroot/css/calendar-base-css.css b/wwwroot/css/calendar-base-css.css index 2404c06..8843133 100644 --- a/wwwroot/css/calendar-base-css.css +++ b/wwwroot/css/calendar-base-css.css @@ -102,7 +102,7 @@ swp-header-cell, swp-time-cell, swp-day-cell, swp-events-container, -swp-event, +swp-day-columns swp-event, swp-loading-overlay, swp-nav-group, swp-nav-button, @@ -117,7 +117,7 @@ swp-date-range, swp-day-name, swp-day-date, swp-event-time, -swp-event-title, +swp-day-columns swp-event-title, swp-spinner { display: block; } @@ -151,11 +151,11 @@ swp-spinner { swp-calendar-container, swp-calendar-grid, swp-day-column, -swp-event, -swp-event-group, +swp-day-columns swp-event, +swp-day-columns swp-event-group, swp-time-axis, -swp-event-title, -swp-event-time { +swp-day-columns swp-event-title, +swp-day-columns swp-event-time { user-select: none; -webkit-user-select: none; -moz-user-select: none; @@ -163,8 +163,8 @@ swp-event-time { } /* Enable text selection for events when double-clicked */ -swp-event.text-selectable swp-event-title, -swp-event.text-selectable swp-event-time { +swp-day-columns swp-event.text-selectable swp-day-columns swp-event-title, +swp-day-columns swp-event.text-selectable swp-day-columns swp-event-time { user-select: text; -webkit-user-select: text; -moz-user-select: text; diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css index 260de05..adb7cd1 100644 --- a/wwwroot/css/calendar-events-css.css +++ b/wwwroot/css/calendar-events-css.css @@ -1,7 +1,7 @@ /* styles/components/events.css */ /* Event base styles */ -swp-event { +swp-day-columns swp-event { position: absolute; border-radius: 3px; overflow: hidden; @@ -53,20 +53,20 @@ swp-event { } -swp-event:hover { +swp-day-columns swp-event:hover { box-shadow: var(--shadow-md); transform: translateX(2px); z-index: 20; } -swp-event-time { +swp-day-columns swp-event-time { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 4px; } -swp-event-title { +swp-day-columns swp-event-title { display: block; font-size: 0.875rem; line-height: 1.3; diff --git a/wwwroot/css/calendar-layout-css.css b/wwwroot/css/calendar-layout-css.css index f55cf55..742b0ed 100644 --- a/wwwroot/css/calendar-layout-css.css +++ b/wwwroot/css/calendar-layout-css.css @@ -461,7 +461,7 @@ swp-events-layer { pointer-events: none; /* Allow clicks to pass through to day column */ } -swp-event { +swp-day-columns swp-event { pointer-events: auto; } diff --git a/wwwroot/css/calendar.css b/wwwroot/css/calendar.css deleted file mode 100644 index 95e2017..0000000 --- a/wwwroot/css/calendar.css +++ /dev/null @@ -1,345 +0,0 @@ -/* Base CSS - Variables are defined in calendar-base-css.css */ - - - -/* Custom elements default display */ -swp-calendar, -swp-calendar-nav, -swp-calendar-container, -swp-time-axis, -swp-calendar-header, -swp-scrollable-content, -swp-time-grid, -swp-day-columns, -swp-day-column, -swp-events-layer, -swp-event, -swp-loading-overlay, -swp-grid-container, -swp-grid-lines { - display: block; -} - -/* Main calendar container */ -swp-calendar { - display: flex; - flex-direction: column; - height: 100vh; - background: var(--color-background); - position: relative; -} - -/* Navigation bar */ -swp-calendar-nav { - display: grid; - grid-template-columns: auto 1fr auto auto; - align-items: center; - gap: 20px; - padding: 12px 16px; - background: var(--color-background); - border-bottom: 1px solid var(--color-border); - box-shadow: var(--shadow-sm); -} - -swp-nav-group { - display: flex; - align-items: center; - gap: 4px; -} - -swp-nav-button { - display: flex; - align-items: center; - justify-content: center; - padding: 8px 16px; - border: 1px solid var(--color-border); - background: var(--color-background); - border-radius: 4px; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all 150ms ease; - min-width: 40px; - height: 36px; -} - -swp-nav-button:hover { - background: var(--color-surface); - border-color: var(--color-text-secondary); -} - -/* Search container */ -swp-search-container { - display: flex; - align-items: center; - position: relative; - justify-self: end; -} - -swp-search-icon { - position: absolute; - left: 12px; - pointer-events: none; - color: var(--color-text-secondary); - display: flex; - align-items: center; -} - -swp-search-icon svg { - width: 16px; - height: 16px; -} - -swp-search-container input[type="search"] { - padding: 8px 36px 8px 36px; - border: 1px solid var(--color-border); - border-radius: 20px; - background: var(--color-surface); - font-size: 0.875rem; - width: 200px; - transition: all 150ms ease; -} - -swp-search-container input[type="search"]::-webkit-search-cancel-button { - display: none; -} - -swp-search-container input[type="search"]:focus { - outline: none; - border-color: var(--color-primary); - background: var(--color-background); - width: 250px; - box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); -} - -swp-search-container input[type="search"]::placeholder { - color: var(--color-text-secondary); -} - -swp-search-clear { - position: absolute; - right: 8px; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - border-radius: 50%; - transition: all 150ms ease; - color: var(--color-text-secondary); -} - -swp-search-clear:hover { - background: rgba(0, 0, 0, 0.1); -} - -swp-search-clear svg { - width: 12px; - height: 12px; -} - -swp-search-clear[hidden] { - display: none; -} - -swp-view-button { - padding: 8px 16px; - border: none; - background: transparent; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all 150ms ease; -} - -swp-view-button:not(:last-child) { - border-right: 1px solid var(--color-border); -} - -swp-view-button[data-active="true"] { - background: var(--color-primary); - color: white; -} - - -/* Week container for sliding */ -swp-grid-container { - grid-column: 2; - display: grid; - grid-template-rows: auto 1fr; - position: relative; - width: 100%; - transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1); -} - -swp-grid-container.slide-out-left { - transform: translateX(-100%); -} - -swp-grid-container.slide-out-right { - transform: translateX(100%); -} - -swp-grid-container.slide-in-left { - transform: translateX(-100%); -} - -swp-grid-container.slide-in-right { - transform: translateX(100%); -} - -/* Time axis */ -swp-time-axis { - grid-column: 1; - grid-row: 1; - background: var(--color-surface); - border-right: 1px solid var(--color-border); - position: sticky; - left: 0; - z-index: 4; -} - -swp-day-date { - display: block; - font-size: 1.25rem; - font-weight: 600; - margin-top: 4px; -} - -swp-day-header[data-today="true"] swp-day-date { - color: var(--color-primary); - background: rgba(33, 150, 243, 0.1); - border-radius: 50%; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - margin: 4px auto 0; -} - -/* Scrollable content */ -swp-scrollable-content { - overflow-y: auto; - overflow-x: hidden; - scroll-behavior: smooth; - position: relative; -} - -/* Time grid */ -swp-time-grid { - position: relative; - height: calc(12 * var(--hour-height)); -} - - - - -swp-events-layer { - position: absolute; - inset: 0; -} - -/* Events */ -swp-event { - position: absolute; - border-radius: 4px; - overflow: hidden; - cursor: move; - transition: box-shadow 150ms ease, transform 150ms ease; - z-index: 10; - left: 1px; - right: 1px; - padding: 8px; -} - -swp-event[data-type="meeting"] { - background: var(--color-event-meeting); - border-left: 4px solid var(--color-event-meeting-border); -} - -swp-event[data-type="meal"] { - background: var(--color-event-meal); - border-left: 4px solid var(--color-event-meal-border); -} - -swp-event[data-type="work"] { - background: var(--color-event-work); - border-left: 4px solid var(--color-event-work-border); -} - -swp-event:hover { - box-shadow: var(--shadow-md); - filter: brightness(0.95); - z-index: 20; -} - -swp-event-time { - display: block; - font-size: 0.875rem; - font-weight: 500; - opacity: 0.8; - margin-bottom: 4px; -} - -swp-event-title { - display: block; - font-size: 0.875rem; - line-height: 1.3; -} - -/* Loading */ -swp-loading-overlay { - position: absolute; - inset: 0; - background: rgba(255, 255, 255, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 200; -} - -swp-loading-overlay[hidden] { - display: none; -} - -swp-spinner { - width: 40px; - height: 40px; - border: 3px solid #f3f3f3; - border-top: 3px solid var(--color-primary); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* Week info styles */ -swp-week-info { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -} - -swp-week-number { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text); -} - -swp-date-range { - font-size: 0.875rem; - color: var(--color-text-secondary); -} - -swp-view-selector { - display: flex; - border: 1px solid var(--color-border); - border-radius: 4px; - overflow: hidden; -} \ No newline at end of file