Calendar/coding-sessions/2025-11-13-datasource-architecture-implementation.md
Janus C. H. Knudsen f86ae09ec3 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
2025-11-14 16:25:03 +01:00

24 KiB

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):

// 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:

// 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:

// 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:

// 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:

// 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:

// 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.

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:

// GridManager calculated dates itself
const weekStart = this.getISOWeekStart(this.currentDate);
const workWeekSettings = this.config.getWorkWeekSettings();
const dates = this.dateService.getWorkWeekDates(weekStart, workWeekSettings.workDays);

After:

// 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:

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:

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:

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):

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):

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:

// 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:

// 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:

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:

// 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:

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.