345 lines
9.7 KiB
Markdown
345 lines
9.7 KiB
Markdown
|
|
# 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:
|
||
|
|
```html
|
||
|
|
<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**
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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
|
||
|
|
|
||
|
|
```html
|
||
|
|
<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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class SwpEventElement extends BaseSwpEventElement {
|
||
|
|
private static indexedDB: IndexedDBService;
|
||
|
|
|
||
|
|
static setIndexedDB(db: IndexedDBService): void {
|
||
|
|
SwpEventElement.indexedDB = db;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 1.3 Implement Async Data Loading
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// After IndexedDB initialization
|
||
|
|
const indexedDB = new IndexedDBService();
|
||
|
|
await indexedDB.initialize();
|
||
|
|
|
||
|
|
// Set reference in SwpEventElement
|
||
|
|
SwpEventElement.setIndexedDB(indexedDB);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Data Flow
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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.
|