Calendar/coding-sessions/2025-11-12-indexeddb-only-dom-optimization.md
Janus C. H. Knudsen b5dfd57d9e Migrates date handling from date-fns to day.js
Replaces date-fns library with day.js to reduce bundle size and improve tree-shaking

- Centralizes all date logic in DateService
- Reduces library footprint from 576 KB to 29 KB
- Maintains 99.4% test coverage during migration
- Adds timezone and formatting plugins for day.js

Improves overall library performance and reduces dependency complexity
2025-11-12 23:51:48 +01:00

9.7 KiB

IndexedDB-Only DOM Optimization Plan

Date: 2025-11-12
Status: Planning Phase
Goal: Reduce DOM data-attributes to only event ID, using IndexedDB as single source of truth

Current Problem

Events currently store all data in DOM attributes:

<swp-event 
  data-event-id="123"
  data-title="Meeting"
  data-description="Long description..."
  data-start="2025-11-10T10:00:00Z"
  data-end="2025-11-10T11:00:00Z"
  data-type="work"
  data-duration="60"
/>

Issues:

  • Data duplication (IndexedDB + DOM)
  • Synchronization complexity
  • Large DOM size with descriptions
  • Memory overhead

Proposed Solution

Architecture Principle

Single Source of Truth: IndexedDB

graph TB
    A[IndexedDB] -->|getEvent| B[SwpEventElement]
    B -->|Only stores| C[data-event-id]
    B -->|Renders from| D[ICalendarEvent]
    A -->|Provides| D

Target DOM Structure

<swp-event data-event-id="123" />

Only 1 attribute instead of 8+.

Implementation Plan

Phase 1: Refactor SwpEventElement

File: src/elements/SwpEventElement.ts

1.1 Remove Getters/Setters

Remove all property getters/setters except eventId:

  • Remove: start, end, title, description, type
  • Keep: eventId

1.2 Add IndexedDB Reference

export class SwpEventElement extends BaseSwpEventElement {
  private static indexedDB: IndexedDBService;
  
  static setIndexedDB(db: IndexedDBService): void {
    SwpEventElement.indexedDB = db;
  }
}

1.3 Implement Async Data Loading

async connectedCallback() {
  const event = await this.loadEventData();
  if (event) {
    await this.renderFromEvent(event);
  }
}

private async loadEventData(): Promise<ICalendarEvent | null> {
  return await SwpEventElement.indexedDB.getEvent(this.eventId);
}

1.4 Update Render Method

private async renderFromEvent(event: ICalendarEvent): Promise<void> {
  const timeRange = TimeFormatter.formatTimeRange(event.start, event.end);
  const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
  
  this.innerHTML = `
    <swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
    <swp-event-title>${event.title}</swp-event-title>
    ${event.description ? `<swp-event-description>${event.description}</swp-event-description>` : ''}
  `;
}

Phase 2: Update Factory Method

File: src/elements/SwpEventElement.ts (line 284)

public static fromCalendarEvent(event: ICalendarEvent): SwpEventElement {
  const element = document.createElement('swp-event') as SwpEventElement;
  
  // Only set event ID - all other data comes from IndexedDB
  element.dataset.eventId = event.id;
  
  return element;
}

Phase 3: Update Extract Method

File: src/elements/SwpEventElement.ts (line 303)

public static async extractCalendarEventFromElement(element: HTMLElement): Promise<ICalendarEvent | null> {
  const eventId = element.dataset.eventId;
  if (!eventId) return null;
  
  // Load from IndexedDB instead of reading from DOM
  return await SwpEventElement.indexedDB.getEvent(eventId);
}

Phase 4: Update Position Updates

File: src/elements/SwpEventElement.ts (line 117)

public async updatePosition(columnDate: Date, snappedY: number): Promise<void> {
  // 1. Update visual position
  this.style.top = `${snappedY + 1}px`;
  
  // 2. Load current event data from IndexedDB
  const event = await this.loadEventData();
  if (!event) return;
  
  // 3. Calculate new timestamps
  const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY, event);
  
  // 4. Create new dates
  const startDate = this.dateService.createDateAtTime(columnDate, startMinutes);
  let endDate = this.dateService.createDateAtTime(columnDate, endMinutes);
  
  // Handle cross-midnight
  if (endMinutes >= 1440) {
    const extraDays = Math.floor(endMinutes / 1440);
    endDate = this.dateService.addDays(endDate, extraDays);
  }
  
  // 5. Update in IndexedDB
  const updatedEvent = { ...event, start: startDate, end: endDate };
  await SwpEventElement.indexedDB.saveEvent(updatedEvent);
  
  // 6. Re-render from updated data
  await this.renderFromEvent(updatedEvent);
}

Phase 5: Update Height Updates

File: src/elements/SwpEventElement.ts (line 142)

public async updateHeight(newHeight: number): Promise<void> {
  // 1. Update visual height
  this.style.height = `${newHeight}px`;
  
  // 2. Load current event
  const event = await this.loadEventData();
  if (!event) return;
  
  // 3. Calculate new end time
  const gridSettings = this.config.gridSettings;
  const { hourHeight, snapInterval } = gridSettings;
  
  const rawDurationMinutes = (newHeight / hourHeight) * 60;
  const snappedDurationMinutes = Math.round(rawDurationMinutes / snapInterval) * snapInterval;
  
  const endDate = this.dateService.addMinutes(event.start, snappedDurationMinutes);
  
  // 4. Update in IndexedDB
  const updatedEvent = { ...event, end: endDate };
  await SwpEventElement.indexedDB.saveEvent(updatedEvent);
  
  // 5. Re-render
  await this.renderFromEvent(updatedEvent);
}

Phase 6: Update Calculate Times

File: src/elements/SwpEventElement.ts (line 255)

private calculateTimesFromPosition(snappedY: number, event: ICalendarEvent): { startMinutes: number; endMinutes: number } {
  const gridSettings = this.config.gridSettings;
  const { hourHeight, dayStartHour, snapInterval } = gridSettings;
  
  // Calculate original duration from event data
  const originalDuration = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
  
  // Calculate snapped start minutes
  const minutesFromGridStart = (snappedY / hourHeight) * 60;
  const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
  const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
  
  // Calculate end minutes
  const endMinutes = snappedStartMinutes + originalDuration;
  
  return { startMinutes: snappedStartMinutes, endMinutes };
}

Phase 7: Update DragDropManager

File: src/managers/DragDropManager.ts

All places reading from element.dataset.start, element.dataset.end etc. must change to:

// Before:
const start = new Date(element.dataset.start);
const end = new Date(element.dataset.end);

// After:
const event = await SwpEventElement.extractCalendarEventFromElement(element);
if (!event) return;
const start = event.start;
const end = event.end;

Phase 8: Update Clone Method

File: src/elements/SwpEventElement.ts (line 169)

public async createClone(): Promise<SwpEventElement> {
  const clone = this.cloneNode(true) as SwpEventElement;
  
  // Apply "clone-" prefix to ID
  clone.dataset.eventId = `clone-${this.eventId}`;
  
  // Disable pointer events
  clone.style.pointerEvents = 'none';
  
  // Load event data to get duration
  const event = await this.loadEventData();
  if (event) {
    const duration = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
    clone.dataset.originalDuration = duration.toString();
  }
  
  // Set height from original
  clone.style.height = this.style.height || `${this.getBoundingClientRect().height}px`;
  
  return clone;
}

Phase 9: Initialize IndexedDB Reference

File: src/index.ts

// After IndexedDB initialization
const indexedDB = new IndexedDBService();
await indexedDB.initialize();

// Set reference in SwpEventElement
SwpEventElement.setIndexedDB(indexedDB);

Data Flow

sequenceDiagram
    participant DOM as SwpEventElement
    participant IDB as IndexedDBService
    participant User
    
    User->>DOM: Drag event
    DOM->>IDB: getEvent(id)
    IDB-->>DOM: ICalendarEvent
    DOM->>DOM: Calculate new position
    DOM->>IDB: saveEvent(updated)
    IDB-->>DOM: Success
    DOM->>DOM: renderFromEvent()

Benefits

Minimal DOM: Only 1 attribute instead of 8+
Single Source of Truth: IndexedDB is authoritative
No Duplication: Data only in one place
Scalability: Large descriptions no problem
Simpler Sync: No DOM/IndexedDB mismatch

Potential Challenges

⚠️ Async Complexity: All data operations become async
⚠️ Performance: More IndexedDB lookups
⚠️ Drag Smoothness: Async lookup during drag

Solutions to Challenges

  1. Async Complexity: Use async/await consistently throughout
  2. Performance: IndexedDB is fast enough for our use case
  3. Drag Smoothness: Store data-original-duration during drag to avoid lookup

Files to Modify

  1. ✏️ src/elements/SwpEventElement.ts - Main refactoring
  2. ✏️ src/managers/DragDropManager.ts - Update to use async lookups
  3. ✏️ src/index.ts - Initialize IndexedDB reference
  4. ✏️ src/renderers/EventRenderer.ts - May need async updates
  5. ✏️ src/managers/AllDayManager.ts - May need async updates

Testing Strategy

  1. Test event rendering with only ID in DOM
  2. Test drag & drop with async data loading
  3. Test resize with async data loading
  4. Test performance with many events
  5. Test offline functionality
  6. Test sync after reconnection

Next Steps

  1. Review this plan
  2. Discuss any concerns or modifications
  3. Switch to Code mode for implementation
  4. Implement phase by phase
  5. Test thoroughly after each phase

Note: This is a significant architectural change. We should implement it carefully and test thoroughly at each phase.