Refactor calendar datasource architecture

Centralizes date calculation logic into DateColumnDataSource
Improves separation of concerns across rendering components

Key changes:
- Introduces IColumnInfo interface for flexible column data
- Moves date calculation from multiple managers to dedicated datasource
- Removes duplicate date rendering logic
- Prepares architecture for future resource-based views
This commit is contained in:
Janus C. H. Knudsen 2025-11-14 16:25:03 +01:00
parent 75a2d4913e
commit f86ae09ec3
15 changed files with 885 additions and 76 deletions

View file

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm run build:*)"
],
"deny": [],
"ask": []
}
}

View file

@ -0,0 +1,769 @@
# DataSource Architecture Implementation
**Date:** November 13, 2025
**Type:** Architecture refactoring, Data flow optimization
**Status:** ✅ Complete (with course corrections)
**Main Goal:** Implement DataSource pattern to centralize date calculation and prepare for dual-mode calendar (date-based vs resource-based)
---
## Executive Summary
Successfully implemented DataSource architecture to centralize date calculations and clean up rendering responsibilities. Initial implementation had architectural violations that were corrected through user guidance.
**Key Outcomes:**
- ✅ DateColumnDataSource created as single source of truth for dates
- ✅ GridRenderer responsibility corrected (structure only, not event rendering)
- ✅ ColumnRenderer receives dates instead of calculating them
- ✅ Navigation flow fixed (no duplicate rendering)
- ✅ Clean separation of concerns restored
- ⚠️ Initial conversation compaction caused architectural mistakes
**Code Volume:**
- Created: 2 new files (DateColumnDataSource.ts, ColumnDataSource.ts interface)
- Modified: 5 files (GridManager.ts, NavigationManager.ts, GridRenderer.ts, ColumnRenderer.ts, EventTypes.ts)
---
## User Corrections & Course Changes
### Correction #1: GridRenderer Event Rendering Violation
**What Happened:**
After conversation compaction, I mistakenly added event rendering logic directly into GridRenderer.
**Code I Added (WRONG):**
```typescript
// GridRenderer.ts - renderColumnContainer()
private renderColumnContainer(dates: Date[], events: ICalendarEvent[]): void {
dates.forEach(date => {
const eventsForDate = events.filter(event => {...});
// Created columns...
// ❌ WRONG: Rendering events directly in GridRenderer
this.renderColumnEvents(eventsForDate, eventsLayer);
});
}
// Added entire event layout logic to GridRenderer
private renderColumnEvents() { ... }
private renderGridGroup() { ... }
private renderEvent() { ... }
```
**User Intervention:**
```
"hvorfor har du flyttet eventrendering iind i griidrendere?
det er jo columnsrenderes opgave"
```
followed by:
```
"ja naturligivs da"
```
**Problem:**
- GridRenderer should ONLY create grid structure (skeleton)
- Event rendering is EventRenderingService's responsibility
- ColumnRenderer creates columns, not GridRenderer's loop
- Violated separation of concerns
- Duplicated logic that already existed in DateEventRenderer
**Correct Solution:**
```typescript
// GridRenderer.ts - renderColumnContainer()
private renderColumnContainer(dates: Date[], events: ICalendarEvent[]): void {
// Delegate to ColumnRenderer
this.columnRenderer.render(columnContainer, {
dates: dates,
config: this.config
});
// Events rendered by EventRenderingService via GRID_RENDERED event
}
```
**Lesson:** After conversation compaction, I lost context about architectural boundaries. GridRenderer = structure, EventRenderingService = events, ColumnRenderer = columns. Never mix responsibilities.
---
### Correction #2: NavigationManager Configuration Injection
**What Happened:**
I attempted to inject Configuration into NavigationManager so it could calculate dates.
**User Intervention:**
```
"hvad er det her du har lavet... NavigationManager skal bruge Configuration
for at få adgang til workweek settings, så den kan beregne dates.
Lad mig opdatere det:
ja det vi nok. Men hva dbruger GridManager det event tiil?"
```
**Problem:**
- NavigationManager doesn't need to know about workweek settings
- Date calculation should be DataSource's responsibility
- NavigationManager should focus on animation, not data
**Initial Wrong Approach:**
```typescript
// NavigationManager.ts
constructor(..., config: Configuration) {
this.config = config;
}
private animateTransition() {
const workWeekSettings = this.config.getWorkWeekSettings();
const dates = this.dateService.getWorkWeekDates(targetWeek, workWeekSettings.workDays);
// ❌ NavigationManager calculating dates manually
}
```
**Correct Solution:**
```typescript
// NavigationManager.ts
constructor(..., config: Configuration) {
this.config = config;
this.dataSource = new DateColumnDataSource(dateService, config, this.currentWeek, 'week');
}
private animateTransition() {
this.dataSource.setCurrentDate(targetWeek);
const dates = this.dataSource.getColumns();
// ✅ DataSource calculates dates
}
```
**Lesson:** NavigationManager needed Configuration, but not to calculate dates itself. It needed it to create a DataSource instance, which handles date calculation.
---
### Correction #3: GridManager Navigation Rendering Conflict
**What Happened:**
GridManager was calling `render()` when receiving NAVIGATION_COMPLETED event, creating conflict with NavigationManager's animation.
**User Analysis:**
I explained the problem flow:
```
1. NavigationManager creates NEW grid with animation
2. After animation: emits NAVIGATION_COMPLETED
3. GridManager listens and calls render() → updates OLD grid
4. Result: Two grids, broken state
```
User response:
```
"ja" (approved the fix)
```
**Problem:**
```typescript
// GridManager.ts - BEFORE (WRONG)
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => {
this.currentDate = detail.newDate;
this.dataSource.setCurrentDate(this.currentDate);
this.render(); // ❌ Re-renders, conflicts with NavigationManager's new grid
});
```
**Correct Solution:**
```typescript
// GridManager.ts - AFTER (CORRECT)
// Listen for navigation events from NavigationManager
// NavigationManager has already created new grid with animation
// GridManager only needs to update state, NOT re-render
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => {
this.currentDate = detail.newDate;
this.dataSource.setCurrentDate(this.currentDate);
// Do NOT call render() - NavigationManager already created new grid
});
```
**Lesson:** Two managers can't both render during navigation. NavigationManager owns animation rendering, GridManager only syncs state.
---
## Implementation Details
### Phase 1: DateColumnDataSource Creation
**File Created:** `src/datasources/DateColumnDataSource.ts`
**Purpose:** Single source of truth for date calculation based on workweek settings.
```typescript
export class DateColumnDataSource implements IColumnDataSource {
private dateService: DateService;
private config: Configuration;
private currentDate: Date;
private currentView: CalendarView;
public getColumns(): Date[] {
switch (this.currentView) {
case 'week':
return this.getWeekDates();
case 'month':
return this.getMonthDates();
case 'day':
return [this.currentDate];
}
}
private getWeekDates(): Date[] {
const weekStart = this.getISOWeekStart(this.currentDate);
const workWeekSettings = this.config.getWorkWeekSettings();
return this.dateService.getWorkWeekDates(weekStart, workWeekSettings.workDays);
}
}
```
**Key Methods:**
- `getColumns()` - Returns Date[] based on current view
- `setCurrentDate(date)` - Updates current date
- `setCurrentView(view)` - Switches between day/week/month
- `getType()` - Returns 'date' (vs 'resource' for future mode)
---
### Phase 2: GridManager Integration
**File Modified:** `src/managers/GridManager.ts`
**Before:**
```typescript
// GridManager calculated dates itself
const weekStart = this.getISOWeekStart(this.currentDate);
const workWeekSettings = this.config.getWorkWeekSettings();
const dates = this.dateService.getWorkWeekDates(weekStart, workWeekSettings.workDays);
```
**After:**
```typescript
// GridManager uses DataSource
constructor(...) {
this.dataSource = new DateColumnDataSource(dateService, config, this.currentDate, this.currentView);
}
public async render(): Promise<void> {
const dates = this.dataSource.getColumns(); // Single source of truth
const events = await this.eventManager.getEventsForPeriod(dates[0], dates[dates.length - 1]);
this.gridRenderer.renderGrid(this.container, this.currentDate, this.currentView, dates, events);
eventBus.emit(CoreEvents.GRID_RENDERED, {
container: this.container,
currentDate: this.currentDate,
dates: dates
});
}
```
**Navigation Handling Fixed:**
```typescript
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => {
this.currentDate = detail.newDate;
this.dataSource.setCurrentDate(this.currentDate);
// Removed render() call - NavigationManager already created new grid
});
```
---
### Phase 3: NavigationManager DataSource Integration
**File Modified:** `src/managers/NavigationManager.ts`
**Added:**
```typescript
import { DateColumnDataSource } from '../datasources/DateColumnDataSource';
import { Configuration } from '../configurations/CalendarConfig';
constructor(..., config: Configuration) {
this.config = config;
this.dataSource = new DateColumnDataSource(dateService, config, this.currentWeek, 'week');
}
```
**Animation Flow Updated:**
```typescript
private animateTransition(direction: 'prev' | 'next', targetWeek: Date): void {
// Update DataSource with target week and get dates
this.dataSource.setCurrentDate(targetWeek);
const dates = this.dataSource.getColumns();
// Create new grid with correct dates
newGrid = this.gridRenderer.createNavigationGrid(container, dates);
// Animate transition...
// After animation: emit NAVIGATION_COMPLETED
this.eventBus.emit(CoreEvents.NAVIGATION_COMPLETED, {
direction,
newDate: this.currentWeek
});
}
```
---
### Phase 4: GridRenderer Responsibility Cleanup
**File Modified:** `src/renderers/GridRenderer.ts`
**Removed:**
- WorkHoursManager injection (ColumnRenderer handles it)
- Event rendering logic (EventRenderingService handles it)
- Column creation loop (ColumnRenderer handles it)
**Kept:**
- Grid skeleton creation (swp-grid-container, swp-time-grid)
- Time axis rendering
- Delegation to ColumnRenderer
**Before (WRONG - after conversation compaction):**
```typescript
private renderColumnContainer(dates: Date[], events: ICalendarEvent[]): void {
dates.forEach(date => {
const column = document.createElement('swp-day-column');
// Apply work hours styling
this.applyWorkHoursStyling(column, date);
// Render events
this.renderColumnEvents(eventsForDate, eventsLayer);
});
}
```
**After (CORRECT):**
```typescript
private renderColumnContainer(dates: Date[], events: ICalendarEvent[]): void {
// Delegate to ColumnRenderer
this.columnRenderer.render(columnContainer, {
dates: dates,
config: this.config
});
// Events rendered by EventRenderingService via GRID_RENDERED event
}
```
---
### Phase 5: ColumnRenderer Context Update
**File Modified:** `src/renderers/ColumnRenderer.ts`
**Context Interface Changed:**
```typescript
// Before
export interface IColumnRenderContext {
currentWeek: Date;
config: Configuration;
}
// After
export interface IColumnRenderContext {
dates: Date[]; // ← Receives dates instead of calculating them
config: Configuration;
}
```
**Render Method Updated:**
```typescript
// Before
render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
const { currentWeek, config } = context;
const workWeekSettings = config.getWorkWeekSettings();
const dates = this.dateService.getWorkWeekDates(currentWeek, workWeekSettings.workDays);
// ❌ ColumnRenderer calculating dates
}
// After
render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
const { dates } = context;
// ✅ ColumnRenderer receives dates from DataSource
dates.forEach((date) => {
const column = document.createElement('swp-day-column');
this.applyWorkHoursToColumn(column, date);
// ...
});
}
```
---
## Architecture Flow
### Before DataSource:
```
┌──────────────────┐
│ GridManager │ ← Calculates dates manually
└────────┬─────────┘
┌──────────────────┐
│ GridRenderer │ ← No dates parameter
└────────┬─────────┘
┌──────────────────┐
│ ColumnRenderer │ ← Calculates dates again (duplicate logic)
└──────────────────┘
NavigationManager: Also calculates dates separately
```
### After DataSource:
```
┌─────────────────────┐
│ DateColumnDataSource│ ← Single source of truth for dates
└──────────┬──────────┘
┌────┴────────────────────┐
│ │
┌─────▼────────┐ ┌────────▼──────────┐
│ GridManager │ │ NavigationManager │
└─────┬────────┘ └────────┬──────────┘
│ │
│ dates[] │ dates[]
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ GridRenderer │ │ GridRenderer │
└────────┬─────────┘ │ (createNavGrid) │
│ └──────────────────┘
┌──────────────────┐
│ ColumnRenderer │ ← Receives dates, creates columns
└────────┬─────────┘
┌──────────────────┐
│ swp-day-column │ ← Column structure with work hours
└──────────────────┘
EventRenderingService ← Listens to GRID_RENDERED, renders events
```
---
## Responsibilities After Refactoring
### DateColumnDataSource
- ✅ Calculate dates based on view (day/week/month)
- ✅ Apply workweek settings
- ✅ Single source of truth for dates
### GridManager
- ✅ Coordinate rendering flow
- ✅ Get dates from DataSource
- ✅ Fetch events from EventManager
- ✅ Call GridRenderer with dates + events
- ✅ Emit GRID_RENDERED event
- ❌ NOT calculate dates itself
- ❌ NOT render during NAVIGATION_COMPLETED
### NavigationManager
- ✅ Handle animation
- ✅ Use DataSource to get dates for new grid
- ✅ Create new grid via GridRenderer.createNavigationGrid()
- ✅ Emit NAVIGATION_COMPLETED after animation
- ❌ NOT calculate dates manually
### GridRenderer
- ✅ Create grid skeleton structure
- ✅ Render time axis
- ✅ Delegate column creation to ColumnRenderer
- ❌ NOT create columns directly
- ❌ NOT render events
- ❌ NOT handle work hours styling
### ColumnRenderer
- ✅ Receive dates from context
- ✅ Create swp-day-column elements
- ✅ Apply work hours styling
- ✅ Create eventsLayer structure
- ❌ NOT calculate dates itself
### EventRenderingService
- ✅ Listen to GRID_RENDERED event
- ✅ Render events into columns
- ✅ Handle event layout (stacked vs grid)
- ❌ NOT be called directly by GridRenderer
---
## Challenges & Solutions
### Challenge 1: Conversation Compaction Memory Loss
**Issue:** After conversation compaction, I lost context about architectural boundaries and started violating separation of concerns.
**Impact:**
- Added event rendering to GridRenderer
- Added column creation loop to GridRenderer
- Duplicated logic that already existed
**Solution:**
User corrections guided me back to correct architecture:
1. GridRenderer delegates to ColumnRenderer
2. EventRenderingService handles events via GRID_RENDERED
3. DataSource calculates dates, not managers
**Lesson:** After conversation compaction, explicitly review architectural boundaries before implementing. Ask about responsibilities if unsure.
---
### Challenge 2: Navigation Rendering Conflict
**Issue:** Both NavigationManager and GridManager tried to render during navigation, creating duplicate grids.
**Root Cause:**
```
NavigationManager: Creates NEW grid with animation
GridManager: Receives NAVIGATION_COMPLETED → renders AGAIN
Result: Two grids, broken state
```
**Solution:**
GridManager only updates state on NAVIGATION_COMPLETED, doesn't re-render:
```typescript
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => {
this.currentDate = detail.newDate;
this.dataSource.setCurrentDate(this.currentDate);
// Removed render() call
});
```
---
### Challenge 3: DataSource Scope Confusion
**Issue:** Initially unsure whether NavigationManager should calculate dates or use DataSource.
**User Guidance:**
NavigationManager should use DateColumnDataSource just like GridManager, ensuring consistent date calculation.
**Solution:**
Both managers create their own DataSource instances:
```typescript
// GridManager
this.dataSource = new DateColumnDataSource(dateService, config, this.currentDate, this.currentView);
// NavigationManager
this.dataSource = new DateColumnDataSource(dateService, config, this.currentWeek, 'week');
```
---
## Navigation Flow (Final)
### User Clicks "Next Week":
```
1. NavigationButtons
├─ Calculates newDate (next week)
└─ emit NAV_BUTTON_CLICKED { direction: 'next', newDate }
2. NavigationManager (listens to NAV_BUTTON_CLICKED)
├─ Updates DataSource with newDate
├─ Gets dates from DataSource.getColumns()
├─ Creates NEW grid: createNavigationGrid(dates)
├─ Animates slide transition (old grid out, new grid in)
└─ After animation: emit NAVIGATION_COMPLETED { direction: 'next', newDate }
3. GridManager (listens to NAVIGATION_COMPLETED)
├─ Updates currentDate
├─ Updates dataSource.setCurrentDate()
└─ Does NOT call render() ← Key fix!
4. EventRenderingService (listens to GRID_RENDERED - emitted during step 2)
├─ Reads GRID_RENDERED payload { container, dates }
├─ Fetches events for date range
└─ Renders events into new grid's columns
```
**Result:** Single grid created by NavigationManager with animation, events rendered by EventRenderingService, GridManager stays in sync.
---
## Files Modified
### Created:
1. `src/types/ColumnDataSource.ts` - IColumnDataSource interface
2. `src/datasources/DateColumnDataSource.ts` - Date-based DataSource implementation
### Modified:
1. `src/managers/GridManager.ts`
- Added DateColumnDataSource
- Removed date calculation logic
- Fixed NAVIGATION_COMPLETED handler (removed render() call)
2. `src/managers/NavigationManager.ts`
- Added DateColumnDataSource
- Uses DataSource.getColumns() instead of manual calculation
- Added Configuration injection
3. `src/renderers/GridRenderer.ts`
- Removed WorkHoursManager dependency
- Removed event rendering logic (corrected after mistake)
- Removed column creation loop (delegates to ColumnRenderer)
- Updated createNavigationGrid() to accept dates array
4. `src/renderers/ColumnRenderer.ts`
- Changed IColumnRenderContext to receive dates array
- Removed date calculation logic
- Simplified render() method
5. `src/types/EventTypes.ts`
- No changes needed (considered adding skipRender flag but not required)
---
## Test Results
### Build Status:
```bash
npm run build
# ✅ Success
# Total transform time: 1105.12ms
# No TypeScript errors
```
### Manual Testing Required:
- ❓ Navigation animation works correctly
- ❓ Events render after navigation
- ❓ Work hours styling applied
- ❓ Workweek preset changes reflected
**Note:** No automated tests exist for navigation flow or DataSource.
---
## Lessons Learned
### 1. Conversation Compaction Awareness
- **Problem:** Lost architectural context after compaction
- **Impact:** Violated separation of concerns, added code to wrong places
- **Solution:** After compaction, explicitly review architectural boundaries before coding
- **Prevention:** Ask user to confirm responsibilities if unsure
### 2. Separation of Concerns Is Critical
- **GridRenderer:** Structure only
- **ColumnRenderer:** Columns only
- **EventRenderingService:** Events only
- **DataSource:** Dates only
- **Mixing these leads to maintenance hell**
### 3. Single Source of Truth Pattern
- **Before:** 3 places calculated dates (GridManager, ColumnRenderer, NavigationManager)
- **After:** 1 place calculates dates (DateColumnDataSource)
- **Benefit:** Change workweek logic once, applies everywhere
### 4. Event-Driven Rendering Conflicts
- Two managers can't both render on same event
- NavigationManager owns animation rendering
- GridManager only syncs state during navigation
- Clear ownership prevents bugs
### 5. Dependency Injection Must Match Responsibility
- GridRenderer doesn't need WorkHoursManager (ColumnRenderer does)
- NavigationManager needs Configuration (to create DataSource)
- Only inject what's actually used
---
## Future Considerations
### Potential Improvements
1. **Resource-Based View Support**
- Create `ResourceColumnDataSource` implementing `IColumnDataSource`
- Returns resource columns instead of date columns
- Switch between DateColumnDataSource and ResourceColumnDataSource based on view mode
2. **DataSource Caching**
- Cache calculated dates to avoid recalculation
- Invalidate cache on workweek settings change
- Performance optimization for frequent navigation
3. **Navigation Testing**
- Add integration tests for navigation flow
- Test animation + event rendering coordination
- Verify no duplicate grids created
4. **DataSource Configuration**
- Allow DataSource to be configured via DI container
- Support custom DataSource implementations
- Enable A/B testing of different date calculation strategies
---
## Code Statistics
### Lines Changed:
- Created: ~150 lines (DateColumnDataSource + interface)
- Modified: ~80 lines (GridManager, NavigationManager, GridRenderer, ColumnRenderer)
- Removed: ~60 lines (duplicate date calculations, event rendering in GridRenderer)
- Net: +170 lines
### Complexity:
- Cyclomatic complexity reduced (centralized date logic)
- Coupling reduced (fewer dependencies in GridRenderer)
- Cohesion improved (each class has single responsibility)
---
## Production Readiness
### ✅ Ready for Testing
**Confidence Level:** Medium-High
**Reasons:**
1. Build succeeds without errors
2. Architecture follows separation of concerns
3. All architectural violations corrected
4. User-guided course corrections applied
**Concerns:**
1. No automated tests for navigation flow
2. Manual testing required
3. Event rendering after navigation not verified
4. DataSource not tested with different workweek configs
**Testing Checklist:**
- [ ] Navigate next/previous week
- [ ] Verify events render after animation
- [ ] Check work hours styling applied
- [ ] Change workweek preset and navigate
- [ ] Verify no duplicate grids created
- [ ] Test navigation while events are being dragged (edge case)
**Rollback Plan:**
If navigation breaks:
1. Git revert to before DataSource implementation
2. Restore direct date calculations in managers
3. Restore GridManager render() call on NAVIGATION_COMPLETED
---
## Conclusion
Successfully implemented DataSource architecture despite initial architectural violations caused by conversation compaction. User corrections guided the implementation back to correct separation of concerns.
The migration process revealed:
1. Importance of maintaining architectural awareness after conversation compaction
2. Value of centralized data calculation (DataSource pattern)
3. Critical need for clear responsibility boundaries
4. How event-driven architecture requires careful coordination
**Key Success:** Single source of truth for dates, clean separation of structure/columns/events rendering, navigation rendering conflict resolved.
**Key Learning:** After conversation compaction, always verify architectural boundaries before implementing.
**Status:** ✅ Implementation complete, ready for manual testing.

View file

@ -1,4 +1,4 @@
import { IColumnDataSource } from '../types/ColumnDataSource'; import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource';
import { DateService } from '../utils/DateService'; import { DateService } from '../utils/DateService';
import { Configuration } from '../configurations/CalendarConfig'; import { Configuration } from '../configurations/CalendarConfig';
import { CalendarView } from '../types/CalendarTypes'; import { CalendarView } from '../types/CalendarTypes';
@ -32,17 +32,28 @@ export class DateColumnDataSource implements IColumnDataSource {
/** /**
* Get columns (dates) to display * Get columns (dates) to display
*/ */
public getColumns(): Date[] { public getColumns(): IColumnInfo[] {
let dates: Date[];
switch (this.currentView) { switch (this.currentView) {
case 'week': case 'week':
return this.getWeekDates(); dates = this.getWeekDates();
break;
case 'month': case 'month':
return this.getMonthDates(); dates = this.getMonthDates();
break;
case 'day': case 'day':
return [this.currentDate]; dates = [this.currentDate];
break;
default: default:
return this.getWeekDates(); dates = this.getWeekDates();
} }
// Convert Date[] to IColumnInfo[]
return dates.map(date => ({
identifier: this.dateService.formatISODate(date),
data: date
}));
} }
/** /**

View file

@ -738,7 +738,7 @@ export class DragDropManager {
} }
const dragMouseLeavePayload: IDragMouseLeaveHeaderEventPayload = { const dragMouseLeavePayload: IDragMouseLeaveHeaderEventPayload = {
targetDate: targetColumn.date, targetColumn: targetColumn,
mousePosition: position, mousePosition: position,
originalElement: this.originalElement, originalElement: this.originalElement,
draggedClone: this.draggedClone draggedClone: this.draggedClone

View file

@ -87,20 +87,21 @@ export class GridManager {
return; return;
} }
// Get dates from datasource - single source of truth // Get columns from datasource - single source of truth
const dates = this.dataSource.getColumns(); const columns = this.dataSource.getColumns();
// Get events for the period from EventManager // Extract dates for EventManager query
const dates = columns.map(col => col.data as Date);
const startDate = dates[0]; const startDate = dates[0];
const endDate = dates[dates.length - 1]; const endDate = dates[dates.length - 1];
const events = await this.eventManager.getEventsForPeriod(startDate, endDate); const events = await this.eventManager.getEventsForPeriod(startDate, endDate);
// Delegate to GridRenderer with dates and events // Delegate to GridRenderer with columns and events
this.gridRenderer.renderGrid( this.gridRenderer.renderGrid(
this.container, this.container,
this.currentDate, this.currentDate,
this.currentView, this.currentView,
dates, columns,
events events
); );
@ -108,7 +109,7 @@ export class GridManager {
eventBus.emit(CoreEvents.GRID_RENDERED, { eventBus.emit(CoreEvents.GRID_RENDERED, {
container: this.container, container: this.container,
currentDate: this.currentDate, currentDate: this.currentDate,
dates: dates columns: columns
}); });
} }
} }

View file

@ -4,6 +4,7 @@ import { CoreEvents } from '../constants/CoreEvents';
import { IHeaderRenderer, IHeaderRenderContext } from '../renderers/DateHeaderRenderer'; import { IHeaderRenderer, IHeaderRenderContext } from '../renderers/DateHeaderRenderer';
import { IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IHeaderReadyEventPayload } from '../types/EventTypes'; import { IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IHeaderReadyEventPayload } from '../types/EventTypes';
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { DateColumnDataSource } from '../datasources/DateColumnDataSource';
/** /**
* HeaderManager - Handles all header-related event logic * HeaderManager - Handles all header-related event logic
@ -13,10 +14,12 @@ import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
export class HeaderManager { export class HeaderManager {
private headerRenderer: IHeaderRenderer; private headerRenderer: IHeaderRenderer;
private config: Configuration; private config: Configuration;
private dataSource: DateColumnDataSource;
constructor(headerRenderer: IHeaderRenderer, config: Configuration) { constructor(headerRenderer: IHeaderRenderer, config: Configuration, dataSource: DateColumnDataSource) {
this.headerRenderer = headerRenderer; this.headerRenderer = headerRenderer;
this.config = config; this.config = config;
this.dataSource = dataSource;
// Bind handler methods for event listeners // Bind handler methods for event listeners
this.handleDragMouseEnterHeader = this.handleDragMouseEnterHeader.bind(this); this.handleDragMouseEnterHeader = this.handleDragMouseEnterHeader.bind(this);
@ -43,11 +46,11 @@ export class HeaderManager {
* Handle drag mouse enter header event * Handle drag mouse enter header event
*/ */
private handleDragMouseEnterHeader(event: Event): void { private handleDragMouseEnterHeader(event: Event): void {
const { targetColumn: targetDate, mousePosition, originalElement, draggedClone: cloneElement } = const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
(event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail; (event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
console.log('🎯 HeaderManager: Received drag:mouseenter-header', { console.log('🎯 HeaderManager: Received drag:mouseenter-header', {
targetDate, targetColumn: targetColumn.identifier,
originalElement: !!originalElement, originalElement: !!originalElement,
cloneElement: !!cloneElement cloneElement: !!cloneElement
}); });
@ -57,11 +60,11 @@ export class HeaderManager {
* Handle drag mouse leave header event * Handle drag mouse leave header event
*/ */
private handleDragMouseLeaveHeader(event: Event): void { private handleDragMouseLeaveHeader(event: Event): void {
const { targetDate, mousePosition, originalElement, draggedClone: cloneElement } = const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
(event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail; (event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail;
console.log('🚪 HeaderManager: Received drag:mouseleave-header', { console.log('🚪 HeaderManager: Received drag:mouseleave-header', {
targetDate, targetColumn: targetColumn?.identifier,
originalElement: !!originalElement, originalElement: !!originalElement,
cloneElement: !!cloneElement cloneElement: !!cloneElement
}); });
@ -111,9 +114,13 @@ export class HeaderManager {
// Clear existing content // Clear existing content
calendarHeader.innerHTML = ''; calendarHeader.innerHTML = '';
// Update DataSource with current date and get columns
this.dataSource.setCurrentDate(currentDate);
const columns = this.dataSource.getColumns();
// Render new header content using injected renderer // Render new header content using injected renderer
const context: IHeaderRenderContext = { const context: IHeaderRenderContext = {
currentWeek: currentDate, columns: columns,
config: this.config config: this.config
}; };

View file

@ -191,12 +191,12 @@ export class NavigationManager {
console.log('Calling GridRenderer instead of NavigationRenderer'); console.log('Calling GridRenderer instead of NavigationRenderer');
console.log('Target week:', targetWeek); console.log('Target week:', targetWeek);
// Update DataSource with target week and get dates // Update DataSource with target week and get columns
this.dataSource.setCurrentDate(targetWeek); this.dataSource.setCurrentDate(targetWeek);
const dates = this.dataSource.getColumns(); const columns = this.dataSource.getColumns();
// Always create a fresh container for consistent behavior // Always create a fresh container for consistent behavior
newGrid = this.gridRenderer.createNavigationGrid(container, dates); newGrid = this.gridRenderer.createNavigationGrid(container, columns);
console.groupEnd(); console.groupEnd();

View file

@ -3,6 +3,7 @@
import { Configuration } from '../configurations/CalendarConfig'; import { Configuration } from '../configurations/CalendarConfig';
import { DateService } from '../utils/DateService'; import { DateService } from '../utils/DateService';
import { WorkHoursManager } from '../managers/WorkHoursManager'; import { WorkHoursManager } from '../managers/WorkHoursManager';
import { IColumnInfo } from '../types/ColumnDataSource';
/** /**
* Interface for column rendering strategies * Interface for column rendering strategies
@ -15,7 +16,7 @@ export interface IColumnRenderer {
* Context for column rendering * Context for column rendering
*/ */
export interface IColumnRenderContext { export interface IColumnRenderContext {
dates: Date[]; columns: IColumnInfo[];
config: Configuration; config: Configuration;
} }
@ -35,11 +36,13 @@ export class DateColumnRenderer implements IColumnRenderer {
} }
render(columnContainer: HTMLElement, context: IColumnRenderContext): void { render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
const { dates } = context; const { columns } = context;
dates.forEach((date) => { columns.forEach((columnInfo) => {
const date = columnInfo.data as Date;
const column = document.createElement('swp-day-column'); const column = document.createElement('swp-day-column');
(column as any).dataset.date = this.dateService.formatISODate(date);
column.dataset.columnId = columnInfo.identifier;
// Apply work hours styling // Apply work hours styling
this.applyWorkHoursToColumn(column, date); this.applyWorkHoursToColumn(column, date);

View file

@ -2,6 +2,7 @@
import { Configuration } from '../configurations/CalendarConfig'; import { Configuration } from '../configurations/CalendarConfig';
import { DateService } from '../utils/DateService'; import { DateService } from '../utils/DateService';
import { IColumnInfo } from '../types/ColumnDataSource';
/** /**
* Interface for header rendering strategies * Interface for header rendering strategies
@ -15,7 +16,7 @@ export interface IHeaderRenderer {
* Context for header rendering * Context for header rendering
*/ */
export interface IHeaderRenderContext { export interface IHeaderRenderContext {
currentWeek: Date; columns: IColumnInfo[];
config: Configuration; config: Configuration;
} }
@ -26,26 +27,22 @@ export class DateHeaderRenderer implements IHeaderRenderer {
private dateService!: DateService; private dateService!: DateService;
render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void { render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void {
const { currentWeek, config } = context; const { columns, config } = context;
// FIRST: Always create all-day container as part of standard header structure // FIRST: Always create all-day container as part of standard header structure
const allDayContainer = document.createElement('swp-allday-container'); const allDayContainer = document.createElement('swp-allday-container');
calendarHeader.appendChild(allDayContainer); calendarHeader.appendChild(allDayContainer);
// Initialize date service with timezone and locale from config // Initialize date service with timezone and locale from config
const timezone = config.timeFormatConfig.timezone;
const locale = config.timeFormatConfig.locale; const locale = config.timeFormatConfig.locale;
this.dateService = new DateService(config); this.dateService = new DateService(config);
const workWeekSettings = config.getWorkWeekSettings(); columns.forEach((columnInfo) => {
const dates = this.dateService.getWorkWeekDates(currentWeek, workWeekSettings.workDays); const date = columnInfo.data as Date;
const weekDays = config.dateViewSettings.weekDays;
const daysToShow = dates.slice(0, weekDays);
daysToShow.forEach((date, index) => {
const header = document.createElement('swp-day-header'); const header = document.createElement('swp-day-header');
if (this.dateService.isSameDay(date, new Date())) { if (this.dateService.isSameDay(date, new Date())) {
(header as any).dataset.today = 'true'; header.dataset.today = 'true';
} }
const dayName = this.dateService.getDayName(date, 'long', locale).toUpperCase(); const dayName = this.dateService.getDayName(date, 'long', locale).toUpperCase();
@ -54,7 +51,8 @@ export class DateHeaderRenderer implements IHeaderRenderer {
<swp-day-name>${dayName}</swp-day-name> <swp-day-name>${dayName}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date> <swp-day-date>${date.getDate()}</swp-day-date>
`; `;
(header as any).dataset.date = this.dateService.formatISODate(date);
header.dataset.columnId = columnInfo.identifier;
calendarHeader.appendChild(header); calendarHeader.appendChild(header);
}); });

View file

@ -390,14 +390,15 @@ export class DateEventRenderer implements IEventRenderer {
} }
protected getEventsForColumn(column: HTMLElement, events: ICalendarEvent[]): ICalendarEvent[] { protected getEventsForColumn(column: HTMLElement, events: ICalendarEvent[]): ICalendarEvent[] {
const columnDate = column.dataset.date; const columnId = column.dataset.columnId;
if (!columnDate) { if (!columnId) {
return []; return [];
} }
// Create start and end of day for interval overlap check // Create start and end of day for interval overlap check
const columnStart = this.dateService.parseISO(`${columnDate}T00:00:00`); // In date-mode, columnId is ISO date string like "2024-11-13"
const columnEnd = this.dateService.parseISO(`${columnDate}T23:59:59.999`); const columnStart = this.dateService.parseISO(`${columnId}T00:00:00`);
const columnEnd = this.dateService.parseISO(`${columnId}T23:59:59.999`);
const columnEvents = events.filter(event => { const columnEvents = events.filter(event => {
// Interval overlap: event overlaps with column day if event.start < columnEnd AND event.end > columnStart // Interval overlap: event overlaps with column day if event.start < columnEnd AND event.end > columnStart

View file

@ -91,12 +91,15 @@ export class EventRenderingService {
* Handle GRID_RENDERED event - render events in the current grid * Handle GRID_RENDERED event - render events in the current grid
*/ */
private handleGridRendered(event: CustomEvent): void { private handleGridRendered(event: CustomEvent): void {
const { container, dates } = event.detail; const { container, columns } = event.detail;
if (!container || !dates || dates.length === 0) { if (!container || !columns || columns.length === 0) {
return; return;
} }
// Extract dates from columns
const dates = columns.map((col: any) => col.data as Date);
// Calculate startDate and endDate from dates array // Calculate startDate and endDate from dates array
const startDate = dates[0]; const startDate = dates[0];
const endDate = dates[dates.length - 1]; const endDate = dates[dates.length - 1];

View file

@ -5,6 +5,7 @@ import { eventBus } from '../core/EventBus';
import { DateService } from '../utils/DateService'; import { DateService } from '../utils/DateService';
import { CoreEvents } from '../constants/CoreEvents'; import { CoreEvents } from '../constants/CoreEvents';
import { TimeFormatter } from '../utils/TimeFormatter'; import { TimeFormatter } from '../utils/TimeFormatter';
import { IColumnInfo } from '../types/ColumnDataSource';
/** /**
* GridRenderer - Centralized DOM rendering for calendar grid structure * GridRenderer - Centralized DOM rendering for calendar grid structure
@ -111,7 +112,7 @@ export class GridRenderer {
grid: HTMLElement, grid: HTMLElement,
currentDate: Date, currentDate: Date,
view: CalendarView = 'week', view: CalendarView = 'week',
dates: Date[] = [], columns: IColumnInfo[] = [],
events: ICalendarEvent[] = [] events: ICalendarEvent[] = []
): void { ): void {
@ -124,10 +125,10 @@ export class GridRenderer {
// Only clear and rebuild if grid is empty (first render) // Only clear and rebuild if grid is empty (first render)
if (grid.children.length === 0) { if (grid.children.length === 0) {
this.createCompleteGridStructure(grid, currentDate, view, dates, events); this.createCompleteGridStructure(grid, currentDate, view, columns, events);
} else { } else {
// Optimized update - only refresh dynamic content // Optimized update - only refresh dynamic content
this.updateGridContent(grid, currentDate, view, dates, events); this.updateGridContent(grid, currentDate, view, columns, events);
} }
} }
@ -151,7 +152,7 @@ export class GridRenderer {
grid: HTMLElement, grid: HTMLElement,
currentDate: Date, currentDate: Date,
view: CalendarView, view: CalendarView,
dates: Date[], columns: IColumnInfo[],
events: ICalendarEvent[] events: ICalendarEvent[]
): void { ): void {
// Create all elements in memory first for better performance // Create all elements in memory first for better performance
@ -167,7 +168,7 @@ export class GridRenderer {
fragment.appendChild(timeAxis); fragment.appendChild(timeAxis);
// Create grid container with caching // Create grid container with caching
const gridContainer = this.createOptimizedGridContainer(dates, events); const gridContainer = this.createOptimizedGridContainer(columns, events);
this.cachedGridContainer = gridContainer; this.cachedGridContainer = gridContainer;
fragment.appendChild(gridContainer); fragment.appendChild(gridContainer);
@ -218,7 +219,7 @@ export class GridRenderer {
* @returns Complete grid container element * @returns Complete grid container element
*/ */
private createOptimizedGridContainer( private createOptimizedGridContainer(
dates: Date[], columns: IColumnInfo[],
events: ICalendarEvent[] events: ICalendarEvent[]
): HTMLElement { ): HTMLElement {
const gridContainer = document.createElement('swp-grid-container'); const gridContainer = document.createElement('swp-grid-container');
@ -237,7 +238,7 @@ export class GridRenderer {
// Create column container // Create column container
const columnContainer = document.createElement('swp-day-columns'); const columnContainer = document.createElement('swp-day-columns');
this.renderColumnContainer(columnContainer, dates, events); this.renderColumnContainer(columnContainer, columns, events);
timeGrid.appendChild(columnContainer); timeGrid.appendChild(columnContainer);
scrollableContent.appendChild(timeGrid); scrollableContent.appendChild(timeGrid);
@ -259,12 +260,12 @@ export class GridRenderer {
*/ */
private renderColumnContainer( private renderColumnContainer(
columnContainer: HTMLElement, columnContainer: HTMLElement,
dates: Date[], columns: IColumnInfo[],
events: ICalendarEvent[] events: ICalendarEvent[]
): void { ): void {
// Delegate to ColumnRenderer // Delegate to ColumnRenderer
this.columnRenderer.render(columnContainer, { this.columnRenderer.render(columnContainer, {
dates: dates, columns: columns,
config: this.config config: this.config
}); });
} }
@ -285,14 +286,14 @@ export class GridRenderer {
grid: HTMLElement, grid: HTMLElement,
currentDate: Date, currentDate: Date,
view: CalendarView, view: CalendarView,
dates: Date[], columns: IColumnInfo[],
events: ICalendarEvent[] events: ICalendarEvent[]
): void { ): void {
// Update column container if needed // Update column container if needed
const columnContainer = grid.querySelector('swp-day-columns'); const columnContainer = grid.querySelector('swp-day-columns');
if (columnContainer) { if (columnContainer) {
columnContainer.innerHTML = ''; columnContainer.innerHTML = '';
this.renderColumnContainer(columnContainer as HTMLElement, dates, events); this.renderColumnContainer(columnContainer as HTMLElement, columns, events);
} }
} }
/** /**
@ -308,9 +309,9 @@ export class GridRenderer {
* @param dates - Array of dates to render * @param dates - Array of dates to render
* @returns New grid element ready for animation * @returns New grid element ready for animation
*/ */
public createNavigationGrid(parentContainer: HTMLElement, dates: Date[]): HTMLElement { public createNavigationGrid(parentContainer: HTMLElement, columns: IColumnInfo[]): HTMLElement {
// Create grid structure without events (events rendered by EventRenderingService) // Create grid structure without events (events rendered by EventRenderingService)
const newGrid = this.createOptimizedGridContainer(dates, []); const newGrid = this.createOptimizedGridContainer(columns, []);
// Position new grid for animation - NO transform here, let Animation API handle it // Position new grid for animation - NO transform here, let Animation API handle it
newGrid.style.position = 'absolute'; newGrid.style.position = 'absolute';

View file

@ -1,3 +1,12 @@
/**
* Column information container
* Contains both identifier and actual data for a column
*/
export interface IColumnInfo {
identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode)
data: Date | any; // Date for date-mode, IResource for resource-mode
}
/** /**
* IColumnDataSource - Defines the contract for providing column data * IColumnDataSource - Defines the contract for providing column data
* *
@ -6,10 +15,10 @@
*/ */
export interface IColumnDataSource { export interface IColumnDataSource {
/** /**
* Get the list of column identifiers to render * Get the list of columns to render
* @returns Array of identifiers (dates or resource IDs) * @returns Array of column information
*/ */
getColumns(): Date[]; getColumns(): IColumnInfo[];
/** /**
* Get the type of columns this datasource provides * Get the type of columns this datasource provides

View file

@ -60,7 +60,7 @@ export interface IDragMouseEnterHeaderEventPayload {
// Drag mouse leave header event payload // Drag mouse leave header event payload
export interface IDragMouseLeaveHeaderEventPayload { export interface IDragMouseLeaveHeaderEventPayload {
targetDate: string | null; targetColumn: IColumnBounds | null;
mousePosition: IMousePosition; mousePosition: IMousePosition;
originalElement: HTMLElement| null; originalElement: HTMLElement| null;
draggedClone: HTMLElement| null; draggedClone: HTMLElement| null;

View file

@ -7,7 +7,7 @@ import { IMousePosition } from "../types/DragDropTypes";
export interface IColumnBounds { export interface IColumnBounds {
date: string; identifier: string;
left: number; left: number;
right: number; right: number;
boundingClientRect: DOMRect, boundingClientRect: DOMRect,
@ -31,15 +31,15 @@ export class ColumnDetectionUtils {
// Cache hver kolonnes x-grænser // Cache hver kolonnes x-grænser
columns.forEach(column => { columns.forEach(column => {
const rect = column.getBoundingClientRect(); const rect = column.getBoundingClientRect();
const date = (column as HTMLElement).dataset.date; const identifier = (column as HTMLElement).dataset.columnId;
if (date) { if (identifier) {
this.columnBoundsCache.push({ this.columnBoundsCache.push({
boundingClientRect : rect, boundingClientRect : rect,
element: column as HTMLElement, element: column as HTMLElement,
date, identifier,
left: rect.left, left: rect.left,
right: rect.right, right: rect.right,
index: index++ index: index++
}); });
} }
@ -68,18 +68,15 @@ export class ColumnDetectionUtils {
} }
/** /**
* Get column bounds by Date * Get column bounds by identifier
*/ */
public static getColumnBoundsByDate(date: Date): IColumnBounds | null { public static getColumnBoundsByIdentifier(identifier: string): IColumnBounds | null {
if (this.columnBoundsCache.length === 0) { if (this.columnBoundsCache.length === 0) {
this.updateColumnBoundsCache(); this.updateColumnBoundsCache();
} }
// Convert Date to YYYY-MM-DD format // Find column that matches the identifier
let dateString = date.toISOString().split('T')[0]; let column = this.columnBoundsCache.find(col => col.identifier === identifier);
// Find column that matches the date
let column = this.columnBoundsCache.find(col => col.date === dateString);
return column || null; return column || null;
} }
@ -96,15 +93,15 @@ export class ColumnDetectionUtils {
// Cache hver kolonnes x-grænser // Cache hver kolonnes x-grænser
dayColumns.forEach(column => { dayColumns.forEach(column => {
const rect = column.getBoundingClientRect(); const rect = column.getBoundingClientRect();
const date = (column as HTMLElement).dataset.date; const identifier = (column as HTMLElement).dataset.columnId;
if (date) { if (identifier) {
dayHeaders.push({ dayHeaders.push({
boundingClientRect : rect, boundingClientRect : rect,
element: column as HTMLElement, element: column as HTMLElement,
date, identifier,
left: rect.left, left: rect.left,
right: rect.right, right: rect.right,
index: index++ index: index++
}); });
} }