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( events: await this.eventService.getByDateRange(
this.dateService.startOfDay(date), this.dateService.startOfDay(date),
this.dateService.endOfDay(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; this.currentDate = date;
} }
/**
* Get current date
*/
public getCurrentDate(): Date {
return this.currentDate;
}
/** /**
* Update current view * Update current view
*/ */

View file

@ -42,7 +42,8 @@ export class ResourceColumnDataSource implements IColumnDataSource {
resources.map(async resource => ({ resources.map(async resource => ({
identifier: resource.id, identifier: resource.id,
data: resource, 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>>(); builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
let calendarMode = 'resource' ; let calendarMode = 'date' ;
// Register DataSource and HeaderRenderer based on mode // Register DataSource and HeaderRenderer based on mode
if (calendarMode === 'resource') { if (calendarMode === 'resource') {
builder.registerType(ResourceColumnDataSource).as<IColumnDataSource>(); builder.registerType(ResourceColumnDataSource).as<IColumnDataSource>();

View file

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

View file

@ -457,12 +457,20 @@ export class DragDropManager {
if (!dropTarget) if (!dropTarget)
throw "dropTarget is null"; 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 = { const dragEndPayload: IDragEndEventPayload = {
originalElement: this.originalElement, originalElement: this.originalElement,
draggedClone: this.draggedClone, draggedClone: this.draggedClone,
mousePosition, mousePosition,
originalSourceColumn: this.originalSourceColumn!!, originalSourceColumn: this.originalSourceColumn!!,
finalPosition: { column, snappedY }, // Where drag ended finalPosition: { column, date, resourceId, snappedY },
target: dropTarget target: dropTarget
}; };

View file

@ -196,30 +196,4 @@ export class EventManager {
return false; 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(); const columns = await this.dataSource.getColumns();
// Always create a fresh container for consistent behavior // Always create a fresh container for consistent behavior
newGrid = this.gridRenderer.createNavigationGrid(container, columns); newGrid = this.gridRenderer.createNavigationGrid(container, columns, targetWeek);
console.groupEnd(); console.groupEnd();

View file

@ -18,6 +18,7 @@ export interface IColumnRenderer {
export interface IColumnRenderContext { export interface IColumnRenderContext {
columns: IColumnInfo[]; columns: IColumnInfo[];
config: Configuration; 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'); const column = document.createElement('swp-day-column');
column.dataset.columnId = columnInfo.identifier; column.dataset.columnId = columnInfo.identifier;
column.dataset.date = this.dateService.formatISODate(date);
// Apply work hours styling // Apply work hours styling
this.applyWorkHoursToColumn(column, date); this.applyWorkHoursToColumn(column, date);

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ export interface IColumnInfo {
identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode) identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode)
data: Date | IResource; // Date for date-mode, IResource for resource-mode data: Date | IResource; // Date for date-mode, IResource for resource-mode
events: ICalendarEvent[]; // Events for this column (pre-filtered by datasource) 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; setCurrentDate(date: Date): void;
/**
* Get the current date
*/
getCurrentDate(): Date;
/** /**
* Update the current view (day/week/month) * Update the current view (day/week/month)
* @param view - The new calendar view * @param view - The new calendar view

View file

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

View file

@ -1,4 +1,5 @@
import { ICalendarEvent } from '../types/CalendarTypes'; import { ICalendarEvent } from '../types/CalendarTypes';
import { IColumnInfo } from '../types/ColumnDataSource';
export interface IEventLayout { export interface IEventLayout {
calenderEvent: ICalendarEvent; calenderEvent: ICalendarEvent;
@ -10,11 +11,13 @@ export interface IEventLayout {
} }
export class AllDayLayoutEngine { 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[][]; private tracks: boolean[][];
constructor(weekDates: string[]) { constructor(columns: IColumnInfo[]) {
this.weekDates = weekDates; this.columnIdentifiers = columns.map(col => col.identifier);
this.columnGroups = columns.map(col => col.groupId);
this.tracks = []; this.tracks = [];
} }
@ -25,7 +28,7 @@ export class AllDayLayoutEngine {
let layouts: IEventLayout[] = []; let layouts: IEventLayout[] = [];
// Reset tracks for new calculation // 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 // Filter to only visible events
const visibleEvents = events.filter(event => this.isEventVisible(event)); const visibleEvents = events.filter(event => this.isEventVisible(event));
@ -70,7 +73,7 @@ export class AllDayLayoutEngine {
} }
// Create new track if none available // 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; return this.tracks.length - 1;
} }
@ -88,42 +91,82 @@ export class AllDayLayoutEngine {
/** /**
* Get start day index for event (1-based, 0 if not visible) * 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 { private getEventStartDay(event: ICalendarEvent): number {
const eventStartDate = this.formatDate(event.start); 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 // If event starts before visible range, clip to first visible day
const clippedStartDate = eventStartDate < firstVisibleDate ? firstVisibleDate : eventStartDate; const clippedStartDate = eventStartDate < firstVisibleDate ? firstVisibleDate : eventStartDate;
const dayIndex = this.weekDates.indexOf(clippedStartDate); const dayIndex = this.columnIdentifiers.indexOf(clippedStartDate);
return dayIndex >= 0 ? dayIndex + 1 : 0; 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) * 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 { private getEventEndDay(event: ICalendarEvent): number {
const eventEndDate = this.formatDate(event.end); 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 // If event ends after visible range, clip to last visible day
const clippedEndDate = eventEndDate > lastVisibleDate ? lastVisibleDate : eventEndDate; const clippedEndDate = eventEndDate > lastVisibleDate ? lastVisibleDate : eventEndDate;
const dayIndex = this.weekDates.indexOf(clippedEndDate); const dayIndex = this.columnIdentifiers.indexOf(clippedEndDate);
return dayIndex >= 0 ? dayIndex + 1 : 0; 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 * Check if event is visible in the current date range
*/ */
private isEventVisible(event: ICalendarEvent): boolean { 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 eventStartDate = this.formatDate(event.start);
const eventEndDate = this.formatDate(event.end); const eventEndDate = this.formatDate(event.end);
const firstVisibleDate = this.weekDates[0]; const firstVisibleDate = this.columnIdentifiers[0];
const lastVisibleDate = this.weekDates[this.weekDates.length - 1]; const lastVisibleDate = this.columnIdentifiers[this.columnIdentifiers.length - 1];
// Event overlaps if it doesn't end before visible range starts // Event overlaps if it doesn't end before visible range starts
// AND doesn't start after visible range ends // AND doesn't start after visible range ends

View file

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