diff --git a/.workbench/image.png b/.workbench/image.png new file mode 100644 index 0000000..17aa388 Binary files /dev/null and b/.workbench/image.png differ diff --git a/.workbench/scenarie3.html b/.workbench/scenarie3.html new file mode 100644 index 0000000..e18f154 --- /dev/null +++ b/.workbench/scenarie3.html @@ -0,0 +1,15 @@ +
+ 10:00 - 13:00 + Scenario 3: Event B +
+ 12:30 - 13:30 + Scenario 3: Event D +
+ 09:00 - 15:00 + Scenario 3: Event A + + 11:00 - 12:00 + Scenario 3: Event C + + 16:00 - 22:00 +
\ No newline at end of file diff --git a/src/data/mock-events.json b/src/data/mock-events.json index 7a2f5d6..4eb18d7 100644 --- a/src/data/mock-events.json +++ b/src/data/mock-events.json @@ -2246,5 +2246,434 @@ "duration": 240, "color": "#2196f3" } + }, + { + "id": "S1A", + "title": "Scenario 1: Event A", + "start": "2025-10-06T05:00:00Z", + "end": "2025-10-06T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 300, + "color": "#ff6b6b" + } + }, + { + "id": "S1B", + "title": "Scenario 1: Event B", + "start": "2025-10-06T06:00:00Z", + "end": "2025-10-06T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#4ecdc4" + } + }, + { + "id": "S1C", + "title": "Scenario 1: Event C", + "start": "2025-10-06T08:30:00Z", + "end": "2025-10-06T09:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#ffe66d" + } + }, + { + "id": "S2A", + "title": "Scenario 2: Event A", + "start": "2025-10-06T11:00:00Z", + "end": "2025-10-06T17:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 360, + "color": "#ff6b6b" + } + }, + { + "id": "S2B", + "title": "Scenario 2: Event B", + "start": "2025-10-06T12:00:00Z", + "end": "2025-10-06T13:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4ecdc4" + } + }, + { + "id": "S2C", + "title": "Scenario 2: Event C", + "start": "2025-10-06T13:30:00Z", + "end": "2025-10-06T14:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S2D", + "title": "Scenario 2: Event D", + "start": "2025-10-06T15:00:00Z", + "end": "2025-10-06T16:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#a8e6cf" + } + }, + { + "id": "S3A", + "title": "Scenario 3: Event A", + "start": "2025-10-07T07:00:00Z", + "end": "2025-10-07T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 360, + "color": "#ff6b6b" + } + }, + { + "id": "S3B", + "title": "Scenario 3: Event B", + "start": "2025-10-07T08:00:00Z", + "end": "2025-10-07T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#4ecdc4" + } + }, + { + "id": "S3C", + "title": "Scenario 3: Event C", + "start": "2025-10-07T09:00:00Z", + "end": "2025-10-07T10:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S3D", + "title": "Scenario 3: Event D", + "start": "2025-10-07T10:30:00Z", + "end": "2025-10-07T11:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#a8e6cf" + } + }, + { + "id": "S4A", + "title": "Scenario 4: Event A", + "start": "2025-10-07T14:00:00Z", + "end": "2025-10-07T20:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 360, + "color": "#ff6b6b" + } + }, + { + "id": "S4B", + "title": "Scenario 4: Event B", + "start": "2025-10-07T15:00:00Z", + "end": "2025-10-07T19:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 240, + "color": "#4ecdc4" + } + }, + { + "id": "S4C", + "title": "Scenario 4: Event C", + "start": "2025-10-07T16:00:00Z", + "end": "2025-10-07T18:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#ffe66d" + } + }, + { + "id": "S5A", + "title": "Scenario 5: Event A", + "start": "2025-10-08T05:00:00Z", + "end": "2025-10-08T08:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#ff6b6b" + } + }, + { + "id": "S5B", + "title": "Scenario 5: Event B", + "start": "2025-10-08T06:00:00Z", + "end": "2025-10-08T07:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#4ecdc4" + } + }, + { + "id": "S5C", + "title": "Scenario 5: Event C", + "start": "2025-10-08T06:00:00Z", + "end": "2025-10-08T07:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S6A", + "title": "Scenario 6: Event A", + "start": "2025-10-08T09:00:00Z", + "end": "2025-10-08T12:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#ff6b6b" + } + }, + { + "id": "S6B", + "title": "Scenario 6: Event B", + "start": "2025-10-08T10:00:00Z", + "end": "2025-10-08T11:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 90, + "color": "#4ecdc4" + } + }, + { + "id": "S6C", + "title": "Scenario 6: Event C", + "start": "2025-10-08T10:00:00Z", + "end": "2025-10-08T11:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S6D", + "title": "Scenario 6: Event D", + "start": "2025-10-08T10:30:00Z", + "end": "2025-10-08T10:45:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 15, + "color": "#a8e6cf" + } + }, + { + "id": "S7A", + "title": "Scenario 7: Event A", + "start": "2025-10-09T05:00:00Z", + "end": "2025-10-09T07:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 150, + "color": "#009688" + } + }, + { + "id": "S7B", + "title": "Scenario 7: Event B", + "start": "2025-10-09T05:00:00Z", + "end": "2025-10-09T07:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#ff5722" + } + }, + { + "id": "S8A", + "title": "Scenario 8: Event A", + "start": "2025-10-09T08:00:00Z", + "end": "2025-10-09T09:00:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff6b6b" + } + }, + { + "id": "S8B", + "title": "Scenario 8: Event B", + "start": "2025-10-09T08:15:00Z", + "end": "2025-10-09T09:30:00Z", + "type": "meeting", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 75, + "color": "#4ecdc4" + } + }, + { + "id": "S9A", + "title": "Scenario 9: Event A", + "start": "2025-10-09T10:00:00Z", + "end": "2025-10-09T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ff6b6b" + } + }, + { + "id": "S9B", + "title": "Scenario 9: Event B", + "start": "2025-10-09T10:30:00Z", + "end": "2025-10-09T11:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#4ecdc4" + } + }, + { + "id": "S9C", + "title": "Scenario 9: Event C", + "start": "2025-10-09T11:15:00Z", + "end": "2025-10-09T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 105, + "color": "#ffe66d" + } + }, + { + "id": "S10A", + "title": "Scenario 10: Event A", + "start": "2025-10-10T10:00:00Z", + "end": "2025-10-10T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 180, + "color": "#ff6b6b" + } + }, + { + "id": "S10B", + "title": "Scenario 10: Event B", + "start": "2025-10-10T10:30:00Z", + "end": "2025-10-10T11:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 30, + "color": "#4ecdc4" + } + }, + { + "id": "S10C", + "title": "Scenario 10: Event C", + "start": "2025-10-10T11:30:00Z", + "end": "2025-10-10T12:30:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#ffe66d" + } + }, + { + "id": "S10D", + "title": "Scenario 10: Event D", + "start": "2025-10-10T12:00:00Z", + "end": "2025-10-10T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#a8e6cf" + } + }, + { + "id": "S10E", + "title": "Scenario 10: Event E", + "start": "2025-10-10T12:00:00Z", + "end": "2025-10-10T13:00:00Z", + "type": "work", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 60, + "color": "#dda15e" + } } ] \ No newline at end of file diff --git a/src/managers/EventLayoutCoordinator.ts b/src/managers/EventLayoutCoordinator.ts index f817612..8d4da38 100644 --- a/src/managers/EventLayoutCoordinator.ts +++ b/src/managers/EventLayoutCoordinator.ts @@ -8,6 +8,7 @@ import { CalendarEvent } from '../types/CalendarTypes'; import { EventStackManager, EventGroup, StackLink } from './EventStackManager'; import { PositionUtils } from '../utils/PositionUtils'; +import { calendarConfig } from '../core/CalendarConfig'; export interface GridGroupLayout { events: CalendarEvent[]; @@ -35,56 +36,91 @@ export class EventLayoutCoordinator { } /** - * Calculate complete layout for a column of events + * Calculate complete layout for a column of events (recursive approach) */ public calculateColumnLayout(columnEvents: CalendarEvent[]): ColumnLayout { if (columnEvents.length === 0) { return { gridGroups: [], stackedEvents: [] }; } - // Step 1: Calculate stack levels for ALL events first (to understand overlaps) - const allStackLinks = this.stackManager.createOptimizedStackLinks(columnEvents); - - // Step 2: Find grid candidates (start together ±15 min) - const groups = this.stackManager.groupEventsByStartTime(columnEvents); - const gridGroups = groups.filter(group => { - if (group.events.length <= 1) return false; - group.containerType = this.stackManager.decideContainerType(group); - return group.containerType === 'GRID'; - }); - - // Step 3: Build grid group layouts const gridGroupLayouts: GridGroupLayout[] = []; - const renderedEventIds = new Set(); + const stackedEventLayouts: StackedEventLayout[] = []; + const renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }> = []; + let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime()); - gridGroups.forEach(group => { - const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks); - const earliestEvent = group.events[0]; - const position = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end); - const columns = this.allocateColumns(group.events); + // Process events recursively + while (remaining.length > 0) { + // Take first event + const firstEvent = remaining[0]; - gridGroupLayouts.push({ - events: group.events, - stackLevel: gridStackLevel, - position: { top: position.top + 1 }, - columns - }); + // Find events that could be in GRID with first event + // (start within threshold AND overlap) + const gridSettings = calendarConfig.getGridSettings(); + const thresholdMinutes = gridSettings.gridStartThresholdMinutes; - group.events.forEach(e => renderedEventIds.add(e.id)); - }); + const gridCandidates = [firstEvent]; + for (let i = 1; i < remaining.length; i++) { + const candidate = remaining[i]; + const startDiff = Math.abs(candidate.start.getTime() - firstEvent.start.getTime()) / (1000 * 60); - // Step 4: Build stacked event layouts for remaining events - const remainingEvents = columnEvents.filter(e => !renderedEventIds.has(e.id)); - const stackedEventLayouts: StackedEventLayout[] = remainingEvents.map(event => { - const stackLink = allStackLinks.get(event.id)!; - const position = PositionUtils.calculateEventPosition(event.start, event.end); + // Only add if starts within threshold AND overlaps with firstEvent + if (startDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(firstEvent, candidate)) { + gridCandidates.push(candidate); + } + } - return { - event, - stackLink, - position: { top: position.top + 1, height: position.height - 3 } + // Decide: should this group be GRID or STACK? + const group: EventGroup = { + events: gridCandidates, + containerType: 'NONE', + startTime: firstEvent.start }; - }); + const containerType = this.stackManager.decideContainerType(group); + + if (containerType === 'GRID' && gridCandidates.length > 1) { + // Render as GRID + const gridStackLevel = this.calculateGridGroupStackLevelFromRendered( + gridCandidates, + renderedEventsWithLevels + ); + + const earliestEvent = gridCandidates[0]; + const position = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end); + const columns = this.allocateColumns(gridCandidates); + + gridGroupLayouts.push({ + events: gridCandidates, + stackLevel: gridStackLevel, + position: { top: position.top + 1 }, + columns + }); + + // Mark all events in grid with their stack level + gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel })); + + // Remove all events in this grid from remaining + remaining = remaining.filter(e => !gridCandidates.includes(e)); + } else { + // Render first event as STACKED + const stackLevel = this.calculateStackLevelFromRendered( + firstEvent, + renderedEventsWithLevels + ); + + const position = PositionUtils.calculateEventPosition(firstEvent.start, firstEvent.end); + stackedEventLayouts.push({ + event: firstEvent, + stackLink: { stackLevel }, + position: { top: position.top + 1, height: position.height - 3 } + }); + + // Mark this event with its stack level + renderedEventsWithLevels.push({ event: firstEvent, level: stackLevel }); + + // Remove only first event from remaining + remaining = remaining.slice(1); + } + } return { gridGroups: gridGroupLayouts, @@ -93,33 +129,42 @@ export class EventLayoutCoordinator { } /** - * Calculate stack level for a grid group based on what it overlaps OUTSIDE the group + * Calculate stack level for a grid group based on already rendered events */ - private calculateGridGroupStackLevel( - group: EventGroup, - allEvents: CalendarEvent[], - stackLinks: Map + private calculateGridGroupStackLevelFromRendered( + gridEvents: CalendarEvent[], + renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }> ): number { - const groupEventIds = new Set(group.events.map(e => e.id)); - - // Find all events OUTSIDE this group - const outsideEvents = allEvents.filter(e => !groupEventIds.has(e.id)); - - // Find the highest stackLevel of any event that overlaps with ANY event in the grid group + // Find highest stack level of any rendered event that overlaps with this grid let maxOverlappingLevel = -1; - for (const gridEvent of group.events) { - for (const outsideEvent of outsideEvents) { - if (this.stackManager.doEventsOverlap(gridEvent, outsideEvent)) { - const outsideLink = stackLinks.get(outsideEvent.id); - if (outsideLink) { - maxOverlappingLevel = Math.max(maxOverlappingLevel, outsideLink.stackLevel); - } + for (const gridEvent of gridEvents) { + for (const rendered of renderedEventsWithLevels) { + if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) { + maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level); } } } - // Grid group should be one level above the highest overlapping event + return maxOverlappingLevel + 1; + } + + /** + * Calculate stack level for a single stacked event based on already rendered events + */ + private calculateStackLevelFromRendered( + event: CalendarEvent, + renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }> + ): number { + // Find highest stack level of any rendered event that overlaps with this event + let maxOverlappingLevel = -1; + + for (const rendered of renderedEventsWithLevels) { + if (this.stackManager.doEventsOverlap(event, rendered.event)) { + maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level); + } + } + return maxOverlappingLevel + 1; }