diff --git a/e2e/tests/schedule/schedule-overlap.spec.ts b/e2e/tests/schedule/schedule-overlap.spec.ts new file mode 100644 index 000000000..2a0a49cc2 --- /dev/null +++ b/e2e/tests/schedule/schedule-overlap.spec.ts @@ -0,0 +1,56 @@ +import { test } from '../../fixtures/test.fixture'; + +test.describe('Schedule overlap', () => { + test('should display multiple tasks starting the same time', async ({ + page, + workViewPage, + }) => { + await workViewPage.waitForTaskList(); + + // Navigate to schedule view + await page.getByRole('menuitem', { name: 'Schedule' }).click(); + // Dismiss the scheduling information dialog + await page.locator('button', { hasText: /Cancel/ }).click(); + + const addTask = async (taskDescription: string): Promise => { + // Last day is far enough into the future to avoid any created tasks + // spawning reminder popups to interrupt the test + const lastDayColumn = page.locator('schedule [data-day]').last(); + // Tasks appearing in columns are expected to always allow for a small + // margin to the rightmost column edge for additional tasks to be created + // around the same start time + await lastDayColumn.click({ + position: { + x: await lastDayColumn.evaluate((el) => el.clientWidth - 5), + y: await lastDayColumn.evaluate((el) => el.clientHeight / 2), + }, + }); + + const newTaskInput = page.getByRole('combobox', { name: 'Schedule task...' }); + await newTaskInput.fill(taskDescription); + await newTaskInput.press('Enter'); + }; + + await addTask('task1'); + await addTask('task2'); + await addTask('task3'); + + const checkTaskAccessible = async (taskDescription: string): Promise => { + await page + .locator('schedule-event') + .filter({ hasText: taskDescription }) + // Regardless of how the elements representing tasks overlap, the top + // left corner should always be visible to click on + .click({ position: { x: 0, y: 0 } }); + // Clicking on the task should bring up its details panel + await page + .locator('task-detail-panel') + .filter({ hasText: taskDescription }) + .isVisible(); + }; + + await checkTaskAccessible('task1'); + await checkTaskAccessible('task2'); + await checkTaskAccessible('task3'); + }); +}); diff --git a/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.spec.ts b/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.spec.ts index b214d6131..12b3d782a 100644 --- a/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.spec.ts +++ b/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.spec.ts @@ -93,8 +93,6 @@ describe('mapScheduleDaysToScheduleEvents()', () => { style: 'grid-column: 2; grid-row: 61 / span 12', timeLeftInHours: 1, type: 'Task', - isCloseToOthers: false, - isCloseToOthersFirst: false, }, { data: { @@ -111,8 +109,6 @@ describe('mapScheduleDaysToScheduleEvents()', () => { style: 'grid-column: 2; grid-row: 73 / span 6', timeLeftInHours: 0.5, type: 'Task', - isCloseToOthers: false, - isCloseToOthersFirst: false, }, ], } as any); diff --git a/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.ts b/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.ts index 7eee21b81..c56a1dbf8 100644 --- a/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.ts +++ b/src/app/features/schedule/map-schedule-data/map-schedule-days-to-schedule-events.ts @@ -27,13 +27,13 @@ export const mapScheduleDaysToScheduleEvents = ( type: SVEType.TaskPlannedForDay, style: `height: ${rowSpan * 8}px`, timeLeftInHours, - isCloseToOthers: false, - isCloseToOthersFirst: false, startHours: 0, }; }); - day.entries.forEach((entry, entryIndex) => { + const activeEntries: typeof day.entries = []; + + day.entries.forEach((entry) => { if (entry.type !== SVEType.WorkdayEnd && entry.type !== SVEType.WorkdayStart) { const start = new Date(entry.start); const startHour = start.getHours(); @@ -45,19 +45,6 @@ export const mapScheduleDaysToScheduleEvents = ( const startRow = Math.round(hoursToday * FH) + 1; const timeLeft = entry.duration; - const entryBefore = day.entries[entryIndex - 1]; - const diff = entry.start - entryBefore?.start; - // const isCloseToOthers = entryBefore && diff <= 5 * 60 * 1000 && diff >= 0; - const isCloseToOthers = !!entryBefore && diff === 0; - if ( - isCloseToOthers && - eventsFlat[eventsFlat.length - 1] && - !eventsFlat[eventsFlat.length - 1].isCloseToOthers - ) { - eventsFlat[eventsFlat.length - 1].isCloseToOthers = true; - eventsFlat[eventsFlat.length - 1].isCloseToOthersFirst = true; - } - // NOTE since we only use getMinutes we also need to floor the minutes for timeLeftInHours const timeLeftInHours = Math.floor(timeLeft / 1000 / 60) / 60; const rowSpan = Math.max(1, Math.round(timeLeftInHours * FH)); @@ -73,12 +60,38 @@ export const mapScheduleDaysToScheduleEvents = ( type: entry.type as SVEType, startHours: hoursToday, timeLeftInHours, - isCloseToOthers, - isCloseToOthersFirst: false, - // title: entry.data.title, style: `grid-column: ${dayIndex + 2}; grid-row: ${startRow} / span ${rowSpan}`, data: entry.data, }); + + let overlapCount = 0; + for (let i = 0; i < activeEntries.length; i++) { + if (!activeEntries[i]) { + continue; + } + if ( + entry.start + entry.duration <= activeEntries[i].start || + activeEntries[i].start + activeEntries[i].duration <= entry.start + ) { + delete activeEntries[i]; + } else { + overlapCount += 1; + } + } + + let nextInactiveSlot = activeEntries.findIndex((s) => !s); + if (nextInactiveSlot === -1) { + nextInactiveSlot = activeEntries.length === 0 ? 0 : activeEntries.length; + } + + activeEntries[nextInactiveSlot] = entry; + + if (overlapCount > 0 || nextInactiveSlot > 0) { + eventsFlat[eventsFlat.length - 1].overlap = { + count: overlapCount, + offset: nextInactiveSlot, + }; + } } }); }); diff --git a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts index 7add44c10..fea62879d 100644 --- a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts +++ b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts @@ -678,8 +678,6 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { style: '', startHours: 0, timeLeftInHours: timeInHours, - isCloseToOthersFirst: false, - isCloseToOthers: false, data: task, }; } diff --git a/src/app/features/schedule/schedule-event/schedule-event.component.scss b/src/app/features/schedule/schedule-event/schedule-event.component.scss index 499fd6140..7425aaa68 100644 --- a/src/app/features/schedule/schedule-event/schedule-event.component.scss +++ b/src/app/features/schedule/schedule-event/schedule-event.component.scss @@ -26,11 +26,6 @@ z-index: 2; user-select: none; - // Bring hovered events above others - &:hover { - z-index: 10; - } - // has to be for after elements overflow: visible !important; min-width: 0; @@ -100,9 +95,9 @@ content: ''; position: absolute; top: 0; - bottom: calc(-1 * (var(--margin-bottom) + 1px)); + bottom: 0; left: 0; - right: calc(-1 * (var(--margin-right) + 4px)); + right: 0; z-index: 3; display: block; } diff --git a/src/app/features/schedule/schedule-event/schedule-event.component.ts b/src/app/features/schedule/schedule-event/schedule-event.component.ts index 238c6e7eb..d8f87e9cc 100644 --- a/src/app/features/schedule/schedule-event/schedule-event.component.ts +++ b/src/app/features/schedule/schedule-event/schedule-event.component.ts @@ -74,6 +74,7 @@ export class ScheduleEventComponent { private _taskService = inject(TaskService); readonly T: typeof T = T; + readonly isDragPreview = input(false); readonly isMonthView = input(false); readonly event = input.required(); @@ -177,12 +178,6 @@ export class ScheduleEventComponent { addClass = 'split-start'; } - if (evt.isCloseToOthersFirst) { - addClass += ' close-to-others-first'; - } else if (evt.isCloseToOthers) { - addClass += ' close-to-others'; - } - if (evt.timeLeftInHours <= 1 / 4) { addClass += ' very-short-event'; } @@ -194,7 +189,21 @@ export class ScheduleEventComponent { return evt.type + ' ' + addClass; }); - readonly style = computed(() => this.se().style); + readonly style = computed(() => { + const { overlap, style } = this.se(); + // Arbitrarily chosen value that controls width reduction of this component + // whenever the underlying event duration overlaps with others + const overlapReductionFactor = 0.75; + return ( + (!this.isMonthView() && !this.isDragPreview() && overlap + ? // eslint-disable-next-line no-mixed-operators -- conflicts with prettier formatting + `margin-left: ${100 - 100 * Math.pow(overlapReductionFactor, overlap.offset)}%; ` + + `width: calc(${Math.pow(overlapReductionFactor, overlap.count) * 100}% - var(--margin-right)); ` + + // Content inside the event element can spill out when the width is limited enough + 'overflow: hidden !important; ' + : '') + style + ); + }); private readonly _projectId = computed(() => this.task()?.projectId || null); diff --git a/src/app/features/schedule/schedule-week/schedule-week.component.html b/src/app/features/schedule/schedule-week/schedule-week.component.html index 2d42dd1f6..bb10e0427 100644 --- a/src/app/features/schedule/schedule-week/schedule-week.component.html +++ b/src/app/features/schedule/schedule-week/schedule-week.component.html @@ -166,6 +166,7 @@ [style]="dragPreviewStyle()!" [class.isShiftInsertPreview]="dragPreviewContext()?.kind === 'shift-task'" [class.isScheduleForDay]="isShiftNoScheduleMode()" + [isDragPreview]="true" > @if (dragPreviewLabel()) {
diff --git a/src/app/features/schedule/schedule.model.ts b/src/app/features/schedule/schedule.model.ts index e337c8a32..1fe8b5bc4 100644 --- a/src/app/features/schedule/schedule.model.ts +++ b/src/app/features/schedule/schedule.model.ts @@ -9,10 +9,9 @@ export interface ScheduleEvent { style: string; startHours: number; timeLeftInHours: number; - isCloseToOthersFirst: boolean; - isCloseToOthers: boolean; dayOfMonth?: number; data?: SVE['data']; + overlap?: { count: number; offset: number }; } export interface ScheduleDay {