Refactor calendar datasource and event management

Enhances calendar flexibility by introducing group-based column spanning and improving cross-mode event handling

Adds support for:
- Dynamic column grouping in date and resource modes
- Consistent event drag-and-drop across different calendar views
- More robust all-day event layout calculations

Improves event management logic to handle resource and date mode transitions more elegantly
This commit is contained in:
Janus C. H. Knudsen 2025-11-25 19:04:06 +01:00
parent 17909696ed
commit d8b9f6dabd
16 changed files with 192 additions and 79 deletions

View file

@ -62,7 +62,8 @@ export class DateColumnDataSource implements IColumnDataSource {
events: await this.eventService.getByDateRange(
this.dateService.startOfDay(date),
this.dateService.endOfDay(date)
)
),
groupId: 'week' // All columns in date mode share same group for spanning
}))
);
@ -90,6 +91,13 @@ export class DateColumnDataSource implements IColumnDataSource {
this.currentDate = date;
}
/**
* Get current date
*/
public getCurrentDate(): Date {
return this.currentDate;
}
/**
* Update current view
*/

View file

@ -42,7 +42,8 @@ export class ResourceColumnDataSource implements IColumnDataSource {
resources.map(async resource => ({
identifier: resource.id,
data: resource,
events: await this.eventService.getByResourceAndDateRange(resource.id, startDate, endDate)
events: await this.eventService.getByResourceAndDateRange(resource.id, startDate, endDate),
groupId: resource.id // Each resource is its own group - no spanning across resources
}))
);

View file

@ -141,7 +141,7 @@ async function initializeCalendar(): Promise<void> {
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
let calendarMode = 'resource' ;
let calendarMode = 'date' ;
// Register DataSource and HeaderRenderer based on mode
if (calendarMode === 'resource') {
builder.registerType(ResourceColumnDataSource).as<IColumnDataSource>();

View file

@ -5,6 +5,7 @@ import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine';
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { IColumnDataSource } from '../types/ColumnDataSource';
import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
@ -30,12 +31,13 @@ export class AllDayManager {
private allDayEventRenderer: AllDayEventRenderer;
private eventManager: EventManager;
private dateService: DateService;
private dataSource: IColumnDataSource;
private layoutEngine: AllDayLayoutEngine | null = null;
// State tracking for layout calculation
private currentAllDayEvents: ICalendarEvent[] = [];
private currentWeekDates: IColumnBounds[] = [];
private currentColumns: IColumnBounds[] = [];
// Expand/collapse state
private isExpanded: boolean = false;
@ -45,11 +47,13 @@ export class AllDayManager {
constructor(
eventManager: EventManager,
allDayEventRenderer: AllDayEventRenderer,
dateService: DateService
dateService: DateService,
dataSource: IColumnDataSource
) {
this.eventManager = eventManager;
this.allDayEventRenderer = allDayEventRenderer;
this.dateService = dateService;
this.dataSource = dataSource;
// Sync CSS variable with TypeScript constant to ensure consistency
document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`);
@ -140,7 +144,7 @@ export class AllDayManager {
// Recalculate layout WITHOUT the removed event to compress gaps
const remainingEvents = this.currentAllDayEvents.filter(e => e.id !== eventId);
const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentWeekDates);
const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentColumns);
// Re-render all-day events with compressed layout
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
@ -395,10 +399,18 @@ export class AllDayManager {
// Store current state
this.currentAllDayEvents = events;
this.currentWeekDates = dayHeaders;
this.currentColumns = dayHeaders;
// Initialize layout engine with provided week dates
let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.identifier));
// Map IColumnBounds to IColumnInfo structure (identifier + groupId)
const columns = dayHeaders.map(column => ({
identifier: column.identifier,
groupId: column.element.dataset.groupId || column.identifier,
data: new Date(), // Not used by AllDayLayoutEngine
events: [] // Not used by AllDayLayoutEngine
}));
// Initialize layout engine with column info including groupId
let layoutEngine = new AllDayLayoutEngine(columns);
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
return layoutEngine.calculateLayout(events);
@ -489,23 +501,43 @@ export class AllDayManager {
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = clone.eventId.replace('clone-', '');
const targetDate = this.dateService.parseISO(dragEndEvent.finalPosition.column.identifier);
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
// Determine target date based on mode
let targetDate: Date;
let resourceId: string | undefined;
if (this.dataSource.isResource()) {
// Resource mode: keep event's existing date, set resourceId
targetDate = clone.start;
resourceId = columnIdentifier;
} else {
// Date mode: parse date from column identifier
targetDate = this.dateService.parseISO(columnIdentifier);
}
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate, resourceId });
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate });
// Create new dates preserving time
const newStart = new Date(targetDate);
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
const newEnd = new Date(targetDate);
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
// Update event in repository
await this.eventManager.updateEvent(eventId, {
// Build update payload
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
start: newStart,
end: newEnd,
allDay: true
});
};
if (resourceId) {
updatePayload.resourceId = resourceId;
}
// Update event in repository
await this.eventManager.updateEvent(eventId, updatePayload);
// Remove original timed event
this.fadeOutAndRemove(dragEndEvent.originalElement);
@ -522,7 +554,7 @@ export class AllDayManager {
};
const updatedEvents = [...this.currentAllDayEvents, newEvent];
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentWeekDates);
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height
@ -537,25 +569,45 @@ export class AllDayManager {
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = clone.eventId.replace('clone-', '');
const targetDate = this.dateService.parseISO(dragEndEvent.finalPosition.column.identifier);
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
// Determine target date based on mode
let targetDate: Date;
let resourceId: string | undefined;
if (this.dataSource.isResource()) {
// Resource mode: keep event's existing date, set resourceId
targetDate = clone.start;
resourceId = columnIdentifier;
} else {
// Date mode: parse date from column identifier
targetDate = this.dateService.parseISO(columnIdentifier);
}
// Calculate duration in days
const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start);
// Create new dates preserving time
const newStart = new Date(targetDate);
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
const newEnd = new Date(targetDate);
newEnd.setDate(newEnd.getDate() + durationDays);
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
// Update event in repository
await this.eventManager.updateEvent(eventId, {
// Build update payload
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
start: newStart,
end: newEnd,
allDay: true
});
};
if (resourceId) {
updatePayload.resourceId = resourceId;
}
// Update event in repository
await this.eventManager.updateEvent(eventId, updatePayload);
// Remove original and fade out
this.fadeOutAndRemove(dragEndEvent.originalElement);
@ -564,7 +616,7 @@ export class AllDayManager {
const updatedEvents = this.currentAllDayEvents.map(e =>
e.id === eventId ? { ...e, start: newStart, end: newEnd } : e
);
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentWeekDates);
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height - this also handles overflow classes!

View file

@ -457,12 +457,20 @@ export class DragDropManager {
if (!dropTarget)
throw "dropTarget is null";
// Read date and resourceId directly from DOM
const dateString = column.element.dataset.date;
if (!dateString) {
throw "column.element.dataset.date is not set";
}
const date = new Date(dateString);
const resourceId = column.element.dataset.resourceId; // undefined in date mode
const dragEndPayload: IDragEndEventPayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
mousePosition,
originalSourceColumn: this.originalSourceColumn!!,
finalPosition: { column, snappedY }, // Where drag ended
finalPosition: { column, date, resourceId, snappedY },
target: dropTarget
};

View file

@ -196,30 +196,4 @@ export class EventManager {
return false;
}
}
/**
* Handle remote update from SignalR
* Saves remote event directly (no queue logic yet)
*/
public async handleRemoteUpdate(event: ICalendarEvent): Promise<void> {
try {
// Mark as synced since it comes from remote
const remoteEvent: ICalendarEvent = {
...event,
syncStatus: 'synced'
};
await this.eventService.save(remoteEvent);
this.eventBus.emit(CoreEvents.REMOTE_UPDATE_RECEIVED, {
event: remoteEvent
});
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event: remoteEvent
});
} catch (error) {
console.error(`Failed to handle remote update for event ${event.id}:`, error);
}
}
}

View file

@ -197,7 +197,7 @@ export class NavigationManager {
const columns = await this.dataSource.getColumns();
// Always create a fresh container for consistent behavior
newGrid = this.gridRenderer.createNavigationGrid(container, columns);
newGrid = this.gridRenderer.createNavigationGrid(container, columns, targetWeek);
console.groupEnd();

View file

@ -18,6 +18,7 @@ export interface IColumnRenderer {
export interface IColumnRenderContext {
columns: IColumnInfo[];
config: Configuration;
currentDate?: Date; // Optional: Only used by ResourceColumnRenderer in resource mode
}
/**
@ -43,6 +44,7 @@ export class DateColumnRenderer implements IColumnRenderer {
const column = document.createElement('swp-day-column');
column.dataset.columnId = columnInfo.identifier;
column.dataset.date = this.dateService.formatISODate(date);
// Apply work hours styling
this.applyWorkHoursToColumn(column, date);

View file

@ -53,6 +53,7 @@ export class DateHeaderRenderer implements IHeaderRenderer {
`;
header.dataset.columnId = columnInfo.identifier;
header.dataset.groupId = columnInfo.groupId;
calendarHeader.appendChild(header);
});

View file

@ -165,7 +165,7 @@ export class GridRenderer {
fragment.appendChild(timeAxis);
// Create grid container with caching
const gridContainer = this.createOptimizedGridContainer(columns);
const gridContainer = this.createOptimizedGridContainer(columns, currentDate);
this.cachedGridContainer = gridContainer;
fragment.appendChild(gridContainer);
@ -211,10 +211,12 @@ export class GridRenderer {
* - Column container - created here, populated by ColumnRenderer
*
* @param columns - Array of columns to render (each column contains its events)
* @param currentDate - Current view date
* @returns Complete grid container element
*/
private createOptimizedGridContainer(
columns: IColumnInfo[]
columns: IColumnInfo[],
currentDate: Date
): HTMLElement {
const gridContainer = document.createElement('swp-grid-container');
@ -232,7 +234,7 @@ export class GridRenderer {
// Create column container
const columnContainer = document.createElement('swp-day-columns');
this.renderColumnContainer(columnContainer, columns);
this.renderColumnContainer(columnContainer, columns, currentDate);
timeGrid.appendChild(columnContainer);
scrollableContent.appendChild(timeGrid);
@ -250,15 +252,18 @@ export class GridRenderer {
*
* @param columnContainer - Empty container to populate
* @param columns - Array of columns to render (each column contains its events)
* @param currentDate - Current view date
*/
private renderColumnContainer(
columnContainer: HTMLElement,
columns: IColumnInfo[]
columns: IColumnInfo[],
currentDate: Date
): void {
// Delegate to ColumnRenderer
this.columnRenderer.render(columnContainer, {
columns: columns,
config: this.config
config: this.config,
currentDate: currentDate
});
}
@ -283,7 +288,7 @@ export class GridRenderer {
const columnContainer = grid.querySelector('swp-day-columns');
if (columnContainer) {
columnContainer.innerHTML = '';
this.renderColumnContainer(columnContainer as HTMLElement, columns);
this.renderColumnContainer(columnContainer as HTMLElement, columns, currentDate);
}
}
/**
@ -296,12 +301,13 @@ export class GridRenderer {
* Events will be rendered by EventRenderingService when GRID_RENDERED emits.
*
* @param parentContainer - Container for the new grid
* @param dates - Array of dates to render
* @param columns - Array of columns to render
* @param currentDate - Current view date
* @returns New grid element ready for animation
*/
public createNavigationGrid(parentContainer: HTMLElement, columns: IColumnInfo[]): HTMLElement {
public createNavigationGrid(parentContainer: HTMLElement, columns: IColumnInfo[], currentDate: Date): HTMLElement {
// Create grid structure (events are in columns, rendered by EventRenderingService)
const newGrid = this.createOptimizedGridContainer(columns);
const newGrid = this.createOptimizedGridContainer(columns, currentDate);
// Position new grid for animation - NO transform here, let Animation API handle it
newGrid.style.position = 'absolute';

View file

@ -1,5 +1,6 @@
import { WorkHoursManager } from '../managers/WorkHoursManager';
import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
import { DateService } from '../utils/DateService';
/**
* Resource-based column renderer
@ -10,13 +11,19 @@ import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
*/
export class ResourceColumnRenderer implements IColumnRenderer {
private workHoursManager: WorkHoursManager;
private dateService: DateService;
constructor(workHoursManager: WorkHoursManager) {
constructor(workHoursManager: WorkHoursManager, dateService: DateService) {
this.workHoursManager = workHoursManager;
this.dateService = dateService;
}
render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
const { columns } = context;
const { columns, currentDate } = context;
if (!currentDate) {
throw new Error('ResourceColumnRenderer requires currentDate in context');
}
// Hardcoded work hours for all resources: 09:00 - 18:00
const workHours = { start: 9, end: 18 };
@ -25,6 +32,7 @@ export class ResourceColumnRenderer implements IColumnRenderer {
const column = document.createElement('swp-day-column');
column.dataset.columnId = columnInfo.identifier;
column.dataset.date = this.dateService.formatISODate(currentDate);
// Apply hardcoded work hours to all resource columns
this.applyWorkHoursToColumn(column, workHours);

View file

@ -39,6 +39,7 @@ export class ResourceHeaderRenderer implements IHeaderRenderer {
header.dataset.columnId = columnInfo.identifier;
header.dataset.resourceId = resource.id;
header.dataset.groupId = columnInfo.groupId;
calendarHeader.appendChild(header);
});

View file

@ -9,6 +9,7 @@ export interface IColumnInfo {
identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode)
data: Date | IResource; // Date for date-mode, IResource for resource-mode
events: ICalendarEvent[]; // Events for this column (pre-filtered by datasource)
groupId: string; // Group ID for spanning logic - events can only span columns with same groupId
}
/**
@ -40,6 +41,11 @@ export interface IColumnDataSource {
*/
setCurrentDate(date: Date): void;
/**
* Get the current date
*/
getCurrentDate(): Date;
/**
* Update the current view (day/week/month)
* @param view - The new calendar view

View file

@ -43,6 +43,8 @@ export interface IDragEndEventPayload {
originalSourceColumn: IColumnBounds; // Original column where drag started
finalPosition: {
column: IColumnBounds | null; // Where drag ended
date: Date; // Always present - the date for this position
resourceId?: string; // Only in resource mode
snappedY: number;
};
target: 'swp-day-column' | 'swp-day-header' | null;

View file

@ -1,4 +1,5 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { IColumnInfo } from '../types/ColumnDataSource';
export interface IEventLayout {
calenderEvent: ICalendarEvent;
@ -10,11 +11,13 @@ export interface IEventLayout {
}
export class AllDayLayoutEngine {
private weekDates: string[];
private columnIdentifiers: string[]; // Column identifiers (date or resource ID)
private columnGroups: string[]; // Group ID for each column (same index as columnIdentifiers)
private tracks: boolean[][];
constructor(weekDates: string[]) {
this.weekDates = weekDates;
constructor(columns: IColumnInfo[]) {
this.columnIdentifiers = columns.map(col => col.identifier);
this.columnGroups = columns.map(col => col.groupId);
this.tracks = [];
}
@ -25,7 +28,7 @@ export class AllDayLayoutEngine {
let layouts: IEventLayout[] = [];
// Reset tracks for new calculation
this.tracks = [new Array(this.weekDates.length).fill(false)];
this.tracks = [new Array(this.columnIdentifiers.length).fill(false)];
// Filter to only visible events
const visibleEvents = events.filter(event => this.isEventVisible(event));
@ -70,7 +73,7 @@ export class AllDayLayoutEngine {
}
// Create new track if none available
this.tracks.push(new Array(this.weekDates.length).fill(false));
this.tracks.push(new Array(this.columnIdentifiers.length).fill(false));
return this.tracks.length - 1;
}
@ -88,42 +91,82 @@ export class AllDayLayoutEngine {
/**
* Get start day index for event (1-based, 0 if not visible)
* Clips to group boundaries - events can only span columns with same groupId
*/
private getEventStartDay(event: ICalendarEvent): number {
const eventStartDate = this.formatDate(event.start);
const firstVisibleDate = this.weekDates[0];
const firstVisibleDate = this.columnIdentifiers[0];
// If event starts before visible range, clip to first visible day
const clippedStartDate = eventStartDate < firstVisibleDate ? firstVisibleDate : eventStartDate;
const dayIndex = this.weekDates.indexOf(clippedStartDate);
return dayIndex >= 0 ? dayIndex + 1 : 0;
const dayIndex = this.columnIdentifiers.indexOf(clippedStartDate);
if (dayIndex < 0) return 0;
// Find group start boundary for this column
const groupId = this.columnGroups[dayIndex];
const groupStart = this.getGroupStartIndex(dayIndex, groupId);
// Return the later of event start and group start (1-based)
return Math.max(groupStart, dayIndex) + 1;
}
/**
* Get end day index for event (1-based, 0 if not visible)
* Clips to group boundaries - events can only span columns with same groupId
*/
private getEventEndDay(event: ICalendarEvent): number {
const eventEndDate = this.formatDate(event.end);
const lastVisibleDate = this.weekDates[this.weekDates.length - 1];
const lastVisibleDate = this.columnIdentifiers[this.columnIdentifiers.length - 1];
// If event ends after visible range, clip to last visible day
const clippedEndDate = eventEndDate > lastVisibleDate ? lastVisibleDate : eventEndDate;
const dayIndex = this.weekDates.indexOf(clippedEndDate);
return dayIndex >= 0 ? dayIndex + 1 : 0;
const dayIndex = this.columnIdentifiers.indexOf(clippedEndDate);
if (dayIndex < 0) return 0;
// Find group end boundary for this column
const groupId = this.columnGroups[dayIndex];
const groupEnd = this.getGroupEndIndex(dayIndex, groupId);
// Return the earlier of event end and group end (1-based)
return Math.min(groupEnd, dayIndex) + 1;
}
/**
* Find the start index of a group (0-based)
* Scans backwards from columnIndex to find where this group starts
*/
private getGroupStartIndex(columnIndex: number, groupId: string): number {
let startIndex = columnIndex;
while (startIndex > 0 && this.columnGroups[startIndex - 1] === groupId) {
startIndex--;
}
return startIndex;
}
/**
* Find the end index of a group (0-based)
* Scans forwards from columnIndex to find where this group ends
*/
private getGroupEndIndex(columnIndex: number, groupId: string): number {
let endIndex = columnIndex;
while (endIndex < this.columnGroups.length - 1 && this.columnGroups[endIndex + 1] === groupId) {
endIndex++;
}
return endIndex;
}
/**
* Check if event is visible in the current date range
*/
private isEventVisible(event: ICalendarEvent): boolean {
if (this.weekDates.length === 0) return false;
if (this.columnIdentifiers.length === 0) return false;
const eventStartDate = this.formatDate(event.start);
const eventEndDate = this.formatDate(event.end);
const firstVisibleDate = this.weekDates[0];
const lastVisibleDate = this.weekDates[this.weekDates.length - 1];
const firstVisibleDate = this.columnIdentifiers[0];
const lastVisibleDate = this.columnIdentifiers[this.columnIdentifiers.length - 1];
// Event overlaps if it doesn't end before visible range starts
// AND doesn't start after visible range ends

View file

@ -4140,6 +4140,7 @@
{
"id": "RES-NOV25-001",
"title": "Balayage kort hår",
"description": "Daily team sync - status updates",
"start": "2025-11-25T09:00:00Z",
"end": "2025-11-25T10:30:00Z",
"type": "customer",