Introduces uniform column key concept for calendar events

Refactors column identification with a new buildColumnKey method to support flexible date and resource tracking

Replaces separate dateKey and resourceId handling with a unified columnKey approach
Improves column rendering and event management with more consistent key generation
Simplifies cross-component event tracking and column lookups
This commit is contained in:
Janus C. H. Knudsen 2025-12-13 11:46:57 +01:00
parent 7da88bb977
commit 0eb3bacb41
9 changed files with 99 additions and 71 deletions

View file

@ -52,6 +52,48 @@ export class DateService {
return this.formatDate(date); return this.formatDate(date);
} }
// ============================================
// COLUMN KEY
// ============================================
/**
* Build a uniform columnKey from grouping segments
* Handles any combination of date, resource, team, etc.
*
* @example
* buildColumnKey({ date: '2025-12-09' }) "2025-12-09"
* buildColumnKey({ date: '2025-12-09', resource: 'EMP001' }) "2025-12-09:EMP001"
*/
buildColumnKey(segments: Record<string, string>): string {
// Always put date first if present, then other segments alphabetically
const date = segments.date;
const others = Object.entries(segments)
.filter(([k]) => k !== 'date')
.sort(([a], [b]) => a.localeCompare(b))
.map(([, v]) => v);
return date ? [date, ...others].join(':') : others.join(':');
}
/**
* Parse a columnKey back into segments
* Assumes format: "date:resource:..." or just "date"
*/
parseColumnKey(columnKey: string): { date: string; resource?: string } {
const parts = columnKey.split(':');
return {
date: parts[0],
resource: parts[1]
};
}
/**
* Extract dateKey from columnKey (first segment)
*/
getDateFromColumnKey(columnKey: string): string {
return columnKey.split(':')[0];
}
// ============================================ // ============================================
// TIME CALCULATIONS // TIME CALCULATIONS
// ============================================ // ============================================

View file

@ -19,9 +19,15 @@ export class DateRenderer implements IRenderer {
for (const dateStr of dates) { for (const dateStr of dates) {
const date = this.dateService.parseISO(dateStr); const date = this.dateService.parseISO(dateStr);
// Build columnKey for uniform identification
const segments: Record<string, string> = { date: dateStr };
if (resourceId) segments.resource = resourceId;
const columnKey = this.dateService.buildColumnKey(segments);
// Header // Header
const header = document.createElement('swp-day-header'); const header = document.createElement('swp-day-header');
header.dataset.date = dateStr; header.dataset.date = dateStr;
header.dataset.columnKey = columnKey;
if (resourceId) { if (resourceId) {
header.dataset.resourceId = resourceId; header.dataset.resourceId = resourceId;
} }
@ -34,6 +40,7 @@ export class DateRenderer implements IRenderer {
// Column // Column
const column = document.createElement('swp-day-column'); const column = document.createElement('swp-day-column');
column.dataset.date = dateStr; column.dataset.date = dateStr;
column.dataset.columnKey = columnKey;
if (resourceId) { if (resourceId) {
column.dataset.resourceId = resourceId; column.dataset.resourceId = resourceId;
} }

View file

@ -116,22 +116,24 @@ export class EventRenderer {
*/ */
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> { private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
// Re-render source column (if different from target) // Re-render source column (if different from target)
if (payload.sourceDateKey !== payload.targetDateKey || if (payload.sourceColumnKey !== payload.targetColumnKey) {
payload.sourceResourceId !== payload.targetResourceId) { await this.rerenderColumn(payload.sourceColumnKey);
await this.rerenderColumn(payload.sourceDateKey, payload.sourceResourceId);
} }
// Re-render target column // Re-render target column
await this.rerenderColumn(payload.targetDateKey, payload.targetResourceId); await this.rerenderColumn(payload.targetColumnKey);
} }
/** /**
* Re-render a single column with fresh data from IndexedDB * Re-render a single column with fresh data from IndexedDB
*/ */
private async rerenderColumn(dateKey: string, resourceId?: string): Promise<void> { private async rerenderColumn(columnKey: string): Promise<void> {
const column = this.findColumn(dateKey, resourceId); const column = this.findColumn(columnKey);
if (!column) return; if (!column) return;
// Parse dateKey and resourceId from columnKey
const { date: dateKey, resource: resourceId } = this.dateService.parseColumnKey(columnKey);
// Get date range for this day // Get date range for this day
const startDate = new Date(dateKey); const startDate = new Date(dateKey);
const endDate = new Date(dateKey); const endDate = new Date(dateKey);
@ -174,25 +176,11 @@ export class EventRenderer {
} }
/** /**
* Find a column element by dateKey and optional resourceId * Find a column element by columnKey
*/ */
private findColumn(dateKey: string, resourceId?: string): HTMLElement | null { private findColumn(columnKey: string): HTMLElement | null {
if (!this.container) return null; if (!this.container) return null;
return this.container.querySelector(`swp-day-column[data-column-key="${columnKey}"]`) as HTMLElement;
const columns = this.container.querySelectorAll('swp-day-column');
for (const col of columns) {
const colEl = col as HTMLElement;
if (colEl.dataset.date !== dateKey) continue;
// If resourceId specified, must match
if (resourceId && colEl.dataset.resourceId !== resourceId) continue;
// If no resourceId specified but column has one, skip (simple view case)
if (!resourceId && colEl.dataset.resourceId) continue;
return colEl;
}
return null;
} }
/** /**

View file

@ -26,8 +26,7 @@ interface DragState {
targetY: number; targetY: number;
currentY: number; currentY: number;
animationId: number; animationId: number;
sourceDateKey: string; // Source column date (where drag started) sourceColumnKey: string; // Source column key (where drag started)
sourceResourceId?: string; // Source column resource (where drag started)
dragSource: 'grid' | 'header'; // Where drag originated dragSource: 'grid' | 'header'; // Where drag originated
} }
@ -176,13 +175,12 @@ export class DragDropManager {
) as HTMLElement; ) as HTMLElement;
if (gridEvent) { if (gridEvent) {
const dateKey = this.dragState.currentColumn.dataset.date || ''; const columnKey = this.dragState.currentColumn.dataset.columnKey || '';
const swpEvent = SwpEvent.fromElement(gridEvent, dateKey, this.gridConfig); const swpEvent = SwpEvent.fromElement(gridEvent, columnKey, this.gridConfig);
const payload: IDragEndPayload = { const payload: IDragEndPayload = {
swpEvent, swpEvent,
sourceDateKey: this.dragState.sourceDateKey, sourceColumnKey: this.dragState.sourceColumnKey,
sourceResourceId: this.dragState.sourceResourceId,
target: 'grid' target: 'grid'
}; };
@ -205,21 +203,20 @@ export class DragDropManager {
// Remove ghost // Remove ghost
this.dragState.ghostElement?.remove(); this.dragState.ghostElement?.remove();
// Get dateKey from target column // Get columnKey from target column
const dateKey = this.dragState.columnElement.dataset.date || ''; const columnKey = this.dragState.columnElement.dataset.columnKey || '';
// Create SwpEvent from element (reads top/height/eventId/resourceId from element) // Create SwpEvent from element (reads top/height/eventId from element)
const swpEvent = SwpEvent.fromElement( const swpEvent = SwpEvent.fromElement(
this.dragState.element, this.dragState.element,
dateKey, columnKey,
this.gridConfig this.gridConfig
); );
// Emit drag:end // Emit drag:end
const payload: IDragEndPayload = { const payload: IDragEndPayload = {
swpEvent, swpEvent,
sourceDateKey: this.dragState.sourceDateKey, sourceColumnKey: this.dragState.sourceColumnKey,
sourceResourceId: this.dragState.sourceResourceId,
target: this.inHeader ? 'header' : 'grid' target: this.inHeader ? 'header' : 'grid'
}; };
@ -262,8 +259,7 @@ export class DragDropManager {
targetY: 0, targetY: 0,
currentY: 0, currentY: 0,
animationId: 0, animationId: 0,
sourceDateKey: '', // Will be set from header item data sourceColumnKey: '', // Will be set from header item data
sourceResourceId: undefined,
dragSource: 'header' dragSource: 'header'
}; };
@ -323,8 +319,7 @@ export class DragDropManager {
targetY: Math.max(0, targetY), targetY: Math.max(0, targetY),
currentY: startY, currentY: startY,
animationId: 0, animationId: 0,
sourceDateKey: columnElement.dataset.date || '', sourceColumnKey: columnElement.dataset.columnKey || '',
sourceResourceId: columnElement.dataset.resourceId,
dragSource: 'grid' dragSource: 'grid'
}; };

View file

@ -40,6 +40,9 @@ export class EventPersistenceManager {
return; return;
} }
// Parse resourceId from columnKey if present
const { resource } = this.dateService.parseColumnKey(swpEvent.columnKey);
// Update and save - start/end already calculated in SwpEvent // Update and save - start/end already calculated in SwpEvent
// Set allDay based on drop target: // Set allDay based on drop target:
// - header: allDay = true // - header: allDay = true
@ -48,7 +51,7 @@ export class EventPersistenceManager {
...event, ...event,
start: swpEvent.start, start: swpEvent.start,
end: swpEvent.end, end: swpEvent.end,
resourceId: swpEvent.resourceId ?? event.resourceId, resourceId: resource ?? event.resourceId,
allDay: payload.target === 'header', allDay: payload.target === 'header',
syncStatus: 'pending' syncStatus: 'pending'
}; };
@ -56,13 +59,10 @@ export class EventPersistenceManager {
await this.eventService.save(updatedEvent); await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns // Emit EVENT_UPDATED for EventRenderer to re-render affected columns
const targetDateKey = this.dateService.getDateKey(swpEvent.start);
const updatePayload: IEventUpdatedPayload = { const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id, eventId: updatedEvent.id,
sourceDateKey: payload.sourceDateKey, sourceColumnKey: payload.sourceColumnKey,
sourceResourceId: payload.sourceResourceId, targetColumnKey: swpEvent.columnKey
targetDateKey,
targetResourceId: swpEvent.resourceId
}; };
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload); this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
}; };
@ -92,13 +92,10 @@ export class EventPersistenceManager {
// Emit EVENT_UPDATED for EventRenderer to re-render the column // Emit EVENT_UPDATED for EventRenderer to re-render the column
// Resize stays in same column, so source and target are the same // Resize stays in same column, so source and target are the same
const dateKey = this.dateService.getDateKey(swpEvent.start);
const updatePayload: IEventUpdatedPayload = { const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id, eventId: updatedEvent.id,
sourceDateKey: dateKey, sourceColumnKey: swpEvent.columnKey,
sourceResourceId: swpEvent.resourceId, targetColumnKey: swpEvent.columnKey
targetDateKey: dateKey,
targetResourceId: swpEvent.resourceId
}; };
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload); this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
}; };

View file

@ -251,14 +251,14 @@ export class ResizeManager {
// Remove global resizing class // Remove global resizing class
document.documentElement.classList.remove('swp--resizing'); document.documentElement.classList.remove('swp--resizing');
// Get dateKey from parent column // Get columnKey from parent column
const column = this.resizeState.element.closest('swp-day-column') as HTMLElement; const column = this.resizeState.element.closest('swp-day-column') as HTMLElement;
const dateKey = column?.dataset.date || ''; const columnKey = column?.dataset.columnKey || '';
// Create SwpEvent from element (reads top/height/eventId/resourceId from element) // Create SwpEvent from element (reads top/height/eventId from element)
const swpEvent = SwpEvent.fromElement( const swpEvent = SwpEvent.fromElement(
this.resizeState.element, this.resizeState.element,
dateKey, columnKey,
this.gridConfig this.gridConfig
); );

View file

@ -94,10 +94,8 @@ export interface IEntityDeletedPayload {
// Event update payload (for re-rendering columns after drag/resize) // Event update payload (for re-rendering columns after drag/resize)
export interface IEventUpdatedPayload { export interface IEventUpdatedPayload {
eventId: string; eventId: string;
sourceDateKey: string; // Source column date (where event came from) sourceColumnKey: string; // Source column key (where event came from)
sourceResourceId?: string; // Source column resource targetColumnKey: string; // Target column key (where event landed)
targetDateKey: string; // Target column date (where event landed)
targetResourceId?: string; // Target column resource
} }
// Resource types // Resource types

View file

@ -26,9 +26,8 @@ export interface IDragMovePayload {
} }
export interface IDragEndPayload { export interface IDragEndPayload {
swpEvent: SwpEvent; // Wrapper with element, start, end, eventId, resourceId swpEvent: SwpEvent; // Wrapper with element, start, end, eventId, columnKey
sourceDateKey: string; // Source column date (where drag started) sourceColumnKey: string; // Source column key (where drag started)
sourceResourceId?: string; // Source column resource (where drag started)
target: 'grid' | 'header'; // Where the event was dropped target: 'grid' | 'header'; // Where the event was dropped
} }

View file

@ -7,17 +7,20 @@ import { IGridConfig } from '../core/IGridConfig';
* for start/end times based on element position and grid config. * for start/end times based on element position and grid config.
* *
* Usage: * Usage:
* - All data (eventId, resourceId) is read from element.dataset * - eventId is read from element.dataset
* - columnKey identifies the column uniformly
* - Position (top, height) is read from element.style * - Position (top, height) is read from element.style
* - Factory method `fromElement()` calculates Date objects * - Factory method `fromElement()` calculates Date objects
*/ */
export class SwpEvent { export class SwpEvent {
readonly element: HTMLElement; readonly element: HTMLElement;
readonly columnKey: string;
private _start: Date; private _start: Date;
private _end: Date; private _end: Date;
constructor(element: HTMLElement, start: Date, end: Date) { constructor(element: HTMLElement, columnKey: string, start: Date, end: Date) {
this.element = element; this.element = element;
this.columnKey = columnKey;
this._start = start; this._start = start;
this._end = end; this._end = end;
} }
@ -27,11 +30,6 @@ export class SwpEvent {
return this.element.dataset.eventId || ''; return this.element.dataset.eventId || '';
} }
/** Resource ID from element.dataset.resourceId */
get resourceId(): string | undefined {
return this.element.dataset.resourceId;
}
get start(): Date { get start(): Date {
return this._start; return this._start;
} }
@ -51,17 +49,21 @@ export class SwpEvent {
} }
/** /**
* Factory: Create SwpEvent from element + dateKey * Factory: Create SwpEvent from element + columnKey
* Reads top/height from element.style to calculate start/end * Reads top/height from element.style to calculate start/end
* @param columnKey - Uniform column identifier (e.g. "2025-12-09" or "2025-12-09:EMP001")
*/ */
static fromElement( static fromElement(
element: HTMLElement, element: HTMLElement,
dateKey: string, columnKey: string,
gridConfig: IGridConfig gridConfig: IGridConfig
): SwpEvent { ): SwpEvent {
const topPixels = parseFloat(element.style.top) || 0; const topPixels = parseFloat(element.style.top) || 0;
const heightPixels = parseFloat(element.style.height) || 0; const heightPixels = parseFloat(element.style.height) || 0;
// Extract dateKey from columnKey (first segment)
const dateKey = columnKey.split(':')[0];
// Calculate start from top position // Calculate start from top position
const startMinutesFromGrid = (topPixels / gridConfig.hourHeight) * 60; const startMinutesFromGrid = (topPixels / gridConfig.hourHeight) * 60;
const totalMinutes = (gridConfig.dayStartHour * 60) + startMinutesFromGrid; const totalMinutes = (gridConfig.dayStartHour * 60) + startMinutesFromGrid;
@ -73,6 +75,6 @@ export class SwpEvent {
const durationMinutes = (heightPixels / gridConfig.hourHeight) * 60; const durationMinutes = (heightPixels / gridConfig.hourHeight) * 60;
const end = new Date(start.getTime() + durationMinutes * 60 * 1000); const end = new Date(start.getTime() + durationMinutes * 60 * 1000);
return new SwpEvent(element, start, end); return new SwpEvent(element, columnKey, start, end);
} }
} }