fix(web): keep segmented timeline allocations actionable

This commit is contained in:
2026-04-01 08:54:15 +02:00
parent 6249f61ce1
commit 4edf3a32ac
5 changed files with 334 additions and 48 deletions
+177 -32
View File
@@ -307,6 +307,14 @@ async function openAllocationContextMenuAtOffset(
await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" });
}
async function openContextMenuAtCenter(
page: Page,
locator: ReturnType<Page["locator"]>,
) {
const box = await readBoundingBox(locator);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
}
async function releaseMouse(page: Page) {
await page.mouse.up();
await page.waitForTimeout(120);
@@ -416,15 +424,43 @@ async function findVisibleTimelineEntryId(
}, minimumWidth);
}
async function findVisibleAllocationIdForResize(page: Page, selector: string) {
type VisibleAllocationSegment = {
allocationId: string;
segmentStart: string | null;
segmentEnd: string | null;
};
function allocationSegmentSelector(segment: VisibleAllocationSegment) {
const parts = [
`[data-timeline-entry-type='allocation'][data-allocation-id='${segment.allocationId}']`,
];
if (segment.segmentStart) {
parts.push(`[data-allocation-segment-start='${segment.segmentStart}']`);
}
if (segment.segmentEnd) {
parts.push(`[data-allocation-segment-end='${segment.segmentEnd}']`);
}
return parts.join("");
}
async function findVisibleAllocationSegmentForResize(
page: Page,
selector: string,
): Promise<VisibleAllocationSegment | null> {
return page.locator(selector).evaluateAll((elements) => {
const candidates: Array<{ id: string; score: number }> = [];
let fallbackId: string | null = null;
const candidates: Array<{
allocationId: string;
segmentStart: string | null;
segmentEnd: string | null;
score: number;
}> = [];
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null =
null;
for (const element of elements) {
if (!(element instanceof HTMLElement)) continue;
const id = element.dataset.allocationId;
if (!id) continue;
const allocationId = element.dataset.allocationId;
if (!allocationId) continue;
const rect = element.getBoundingClientRect();
const isVisible =
@@ -439,7 +475,11 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
continue;
}
fallbackId ??= id;
fallback ??= {
allocationId,
segmentStart: element.dataset.allocationSegmentStart ?? null,
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
};
const isPreferred =
rect.width >= 36 &&
@@ -454,11 +494,16 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
const centerX = rect.left + rect.width / 2;
const widthPenalty = Math.abs(rect.width - 96);
const centerPenalty = Math.abs(centerX - window.innerWidth / 2) / 4;
candidates.push({ id, score: widthPenalty + centerPenalty });
candidates.push({
allocationId,
segmentStart: element.dataset.allocationSegmentStart ?? null,
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
score: widthPenalty + centerPenalty,
});
}
candidates.sort((a, b) => a.score - b.score);
return candidates[0]?.id ?? fallbackId;
return candidates[0] ?? fallback;
});
}
@@ -782,9 +827,13 @@ test.describe("Timeline", () => {
.first();
await allocation.click({ button: "right" });
await expect(page.getByText("Loading...")).not.toBeVisible({ timeout: 2000 });
await expect(page.getByText("Hours / day")).toBeVisible();
await expect(page.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
const popover = page.getByTestId("timeline-allocation-popover");
await expect(popover).toBeVisible();
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
await expect(popover.getByText("Hours / day")).toBeVisible();
await expect(popover.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
});
test("right clicking a project header strip opens the project panel", async ({ page }) => {
@@ -852,16 +901,14 @@ test.describe("Timeline", () => {
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationId = await findVisibleAllocationIdForResize(
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationId).toBeTruthy();
expect(allocationSegment).toBeTruthy();
const allocation = page.locator(
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
);
await allocation.click({ button: "right" });
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
await openContextMenuAtCenter(page, allocation);
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
await expect(cancelButton).toBeVisible();
@@ -905,16 +952,14 @@ test.describe("Timeline", () => {
await page.mouse.move(8, 8);
await expect(hoverCard).not.toBeVisible();
const allocationId = await findVisibleAllocationIdForResize(
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationId).toBeTruthy();
expect(allocationSegment).toBeTruthy();
const allocation = page.locator(
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
);
await allocation.click({ button: "right" });
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
await openContextMenuAtCenter(page, allocation);
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
await expect(cancelButton).toBeVisible();
@@ -956,15 +1001,15 @@ test.describe("Timeline", () => {
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationId = await findVisibleAllocationIdForResize(
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationId).toBeTruthy();
expect(allocationSegment).toBeTruthy();
const result = await measureAllocationDragGap(
page,
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
allocationSegmentSelector(allocationSegment!),
);
expect(result.movedDistance).toBeGreaterThan(56);
@@ -981,15 +1026,15 @@ test.describe("Timeline", () => {
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationId = await findVisibleAllocationIdForResize(
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationId).toBeTruthy();
expect(allocationSegment).toBeTruthy();
const result = await measureAllocationResizeGap(
page,
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
allocationSegmentSelector(allocationSegment!),
);
expect(result.widthGain).toBeGreaterThan(64);
@@ -1006,15 +1051,15 @@ test.describe("Timeline", () => {
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationId = await findVisibleAllocationIdForResize(
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationId).toBeTruthy();
expect(allocationSegment).toBeTruthy();
const result = await measureAllocationResizeStartGap(
page,
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
allocationSegmentSelector(allocationSegment!),
);
expect(result.widthGain).toBeGreaterThan(48);
@@ -1447,6 +1492,106 @@ test.describe("Timeline", () => {
}
});
test("segmented allocations stay actionable after switching to project view and reloading", async ({
page,
}) => {
test.setTimeout(60_000);
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const scenario = createTimelineSegmentScenario(suffix);
try {
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
waitUntil: "domcontentloaded",
});
const resourceRow = page
.locator(
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
)
.first();
await expect(resourceRow).toBeVisible();
const baseSegment = resourceRow.locator(
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
);
await expect(baseSegment).toBeVisible();
const baseSegmentBox = await readBoundingBox(baseSegment);
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
expect(dayWidth).toBeGreaterThan(8);
await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5);
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
await carveDateInputs.nth(2).fill("08/04/2026");
await carveDateInputs.nth(3).fill("08/04/2026");
await page.getByRole("button", { name: "Remove Selected Range" }).click();
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-06", endDate: "2026-04-07" },
{ startDate: "2026-04-09", endDate: "2026-04-17" },
]);
const mondaySegment = resourceRow.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
).first();
await expect(mondaySegment).toBeVisible();
await page.getByText("Project view").click();
await expect(page.getByText(/projects/)).toBeVisible();
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
await expect
.poll(() => {
mondayAssignment =
listScenarioAssignments(scenario.projectId).find(
(entry) => entry.startDate === "2026-04-09" && entry.endDate === "2026-04-17",
) ?? null;
return mondayAssignment?.id ?? null;
})
.not.toBeNull();
const projectRow = page.locator(
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`,
).first();
await expect(projectRow).toBeVisible();
const projectAllocation = projectRow.locator(
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
);
await expect(projectAllocation).toBeVisible();
await openContextMenuAtCenter(page, projectAllocation);
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
const popover = page.getByTestId("timeline-allocation-popover");
await expect(popover).toBeVisible();
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
await expect(popover.getByText("Hours / day")).toBeVisible();
await expect(popover.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
await popover.getByRole("button", { name: "Cancel" }).click();
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByText("Project view").click();
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
const projectAllocationAfterReload = page
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`)
.first();
await expect(projectAllocationAfterReload).toBeVisible();
await openContextMenuAtCenter(page, projectAllocationAfterReload);
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
const popoverAfterReload = page.getByTestId("timeline-allocation-popover");
await expect(popoverAfterReload).toBeVisible();
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
await expect(popoverAfterReload.getByText("Hours / day")).toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
}
});
test("project view demand bars expose hover details", async ({ page }) => {
await page.getByText("Project view").click();
await expect(page.getByText(/projects/)).toBeVisible();