mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
fix(schedule): maintain visibility during task overlap on schedule (#5887)
This commit is contained in:
parent
ef171790fb
commit
28c92cf944
8 changed files with 108 additions and 41 deletions
56
e2e/tests/schedule/schedule-overlap.spec.ts
Normal file
56
e2e/tests/schedule/schedule-overlap.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -678,8 +678,6 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
|
|||
style: '',
|
||||
startHours: 0,
|
||||
timeLeftInHours: timeInHours,
|
||||
isCloseToOthersFirst: false,
|
||||
isCloseToOthers: false,
|
||||
data: task,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue