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();
@@ -0,0 +1,96 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AllocationPopover } from "./AllocationPopover.js";
const mockUseQuery = vi.fn();
const mockUseMutation = vi.fn(() => ({ mutate: vi.fn() }));
const mockUseUtils = vi.fn(() => ({
allocation: {
getAssignmentById: { invalidate: vi.fn() },
listView: { invalidate: vi.fn() },
},
}));
vi.mock("~/lib/trpc/client.js", () => ({
trpc: {
useUtils: () => mockUseUtils(),
allocation: {
getAssignmentById: {
useQuery: (...args: unknown[]) => mockUseQuery(...args),
},
},
timeline: {
updateAllocationInline: {
useMutation: () => mockUseMutation(),
},
carveAllocationRange: {
useMutation: () => mockUseMutation(),
},
},
},
}));
vi.mock("~/hooks/useInvalidatePlanningViews.js", () => ({
useInvalidateTimeline: () => vi.fn(),
}));
vi.mock("~/hooks/useViewportPopover.js", () => ({
useViewportPopover: () => ({
ref: { current: null },
style: {},
}),
}));
describe("AllocationPopover", () => {
beforeEach(() => {
mockUseQuery.mockReset();
mockUseMutation.mockClear();
mockUseUtils.mockClear();
});
it("renders an error state when the allocation lookup fails", () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("Assignment not found"),
});
const html = renderToStaticMarkup(
<AllocationPopover
allocationId="assignment_missing"
projectId="project_1"
anchorX={120}
anchorY={40}
onClose={() => {}}
onOpenPanel={() => {}}
/>,
);
expect(html).toContain("data-testid=\"timeline-allocation-popover-error\"");
expect(html).toContain("The selected booking could not be loaded right now.");
expect(html).toContain("Assignment not found");
});
it("renders an unavailable state when the lookup returns no allocation", () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: null,
});
const html = renderToStaticMarkup(
<AllocationPopover
allocationId="assignment_missing"
projectId="project_1"
anchorX={120}
anchorY={40}
onClose={() => {}}
onOpenPanel={() => {}}
/>,
);
expect(html).toContain("data-testid=\"timeline-allocation-popover-unavailable\"");
expect(html).toContain("The selected booking could not be resolved from the current timeline data.");
});
});
@@ -1,9 +1,10 @@
"use client";
import React from "react";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
import type { AllocationLike, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
@@ -43,16 +44,19 @@ export function AllocationPopover({
onClose,
});
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
{ staleTime: 10_000, enabled: !initialAllocation },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => (
entry.id === allocationId
|| entry.entityId === allocationId
|| entry.sourceAllocationId === allocationId
|| getPlanningEntryMutationId(entry) === allocationId
)) as AllocationPopoverAssignment | undefined;
const shouldLoadAllocation = !initialAllocation;
const allocationQuery = trpc.allocation.getAssignmentById.useQuery(
{ id: allocationId },
{
staleTime: 10_000,
enabled: shouldLoadAllocation,
retry: false,
},
);
const fetchedAllocation = allocationQuery.data as AllocationPopoverAssignment | undefined;
const allocation = initialAllocation ?? fetchedAllocation;
const isLoading = shouldLoadAllocation && allocationQuery.isLoading;
const allocationError = shouldLoadAllocation ? allocationQuery.error : null;
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
const [startDate, setStartDate] = useState<string>("");
@@ -79,6 +83,7 @@ export function AllocationPopover({
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
invalidateTimeline();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
void utils.allocation.listView.invalidate();
onClose();
},
@@ -87,6 +92,7 @@ export function AllocationPopover({
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
onSuccess: () => {
invalidateTimeline();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
void utils.allocation.listView.invalidate();
onClose();
},
@@ -122,18 +128,48 @@ export function AllocationPopover({
if (isLoading) {
const loadingPopover = (
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-loading"
className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500"
>
Loading...
</div>
);
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
}
if (allocationError) {
const errorPopover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-error"
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-red-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">
The selected booking could not be loaded right now.
</p>
<p className="text-xs text-red-600">{allocationError.message}</p>
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined" ? errorPopover : createPortal(errorPopover, document.body);
}
if (!allocation) {
const missingPopover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-unavailable"
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
@@ -160,6 +196,8 @@ export function AllocationPopover({
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover"
data-allocation-id={allocationId}
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
>
{/* Header */}
@@ -1146,7 +1146,10 @@ function renderProjectDragHandles(
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
{
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
},
e.clientX,
e.clientY,
);
@@ -812,7 +812,7 @@ function renderAllocBlocksFromData(
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{
allocationId: alloc.id,
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
contextDate: resolveSegmentContextDate(
e.clientX,
@@ -1151,7 +1151,11 @@ function renderDailyBars(
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId, contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) },
{
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())),
},
e.clientX,
e.clientY,
);