fix(schedule): maintain visibility during task overlap on schedule (#5887)

This commit is contained in:
Michael Huynh 2026-01-10 15:47:57 +08:00
parent ef171790fb
commit 28c92cf944
No known key found for this signature in database
GPG key ID: 760127DAE4EDD351
8 changed files with 108 additions and 41 deletions

View file

@ -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<void> => {
// 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<void> => {
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');
});
});

View file

@ -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);

View file

@ -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,
};
}
}
});
});

View file

@ -678,8 +678,6 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
style: '',
startHours: 0,
timeLeftInHours: timeInHours,
isCloseToOthersFirst: false,
isCloseToOthers: false,
data: task,
};
}

View file

@ -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;
}

View file

@ -74,6 +74,7 @@ export class ScheduleEventComponent {
private _taskService = inject(TaskService);
readonly T: typeof T = T;
readonly isDragPreview = input<boolean>(false);
readonly isMonthView = input<boolean>(false);
readonly event = input.required<ScheduleEvent>();
@ -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);

View file

@ -166,6 +166,7 @@
[style]="dragPreviewStyle()!"
[class.isShiftInsertPreview]="dragPreviewContext()?.kind === 'shift-task'"
[class.isScheduleForDay]="isShiftNoScheduleMode()"
[isDragPreview]="true"
>
@if (dragPreviewLabel()) {
<div class="drag-preview-time-badge">

View file

@ -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 {