From 4edf3a32ac9ad9694f8bf4a3d5da5279044ed580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 08:54:15 +0200 Subject: [PATCH] fix(web): keep segmented timeline allocations actionable --- apps/web/e2e/timeline.spec.ts | 209 +++++++++++++++--- .../timeline/AllocationPopover.test.tsx | 96 ++++++++ .../components/timeline/AllocationPopover.tsx | 64 ++++-- .../timeline/TimelineProjectPanel.tsx | 5 +- .../timeline/TimelineResourcePanel.tsx | 8 +- 5 files changed, 334 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/timeline/AllocationPopover.test.tsx diff --git a/apps/web/e2e/timeline.spec.ts b/apps/web/e2e/timeline.spec.ts index 7940056..8bb280d 100644 --- a/apps/web/e2e/timeline.spec.ts +++ b/apps/web/e2e/timeline.spec.ts @@ -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, +) { + 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 { 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(); diff --git a/apps/web/src/components/timeline/AllocationPopover.test.tsx b/apps/web/src/components/timeline/AllocationPopover.test.tsx new file mode 100644 index 0000000..0be8e38 --- /dev/null +++ b/apps/web/src/components/timeline/AllocationPopover.test.tsx @@ -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( + {}} + 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( + {}} + 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."); + }); +}); diff --git a/apps/web/src/components/timeline/AllocationPopover.tsx b/apps/web/src/components/timeline/AllocationPopover.tsx index 72f8129..b5324ee 100644 --- a/apps/web/src/components/timeline/AllocationPopover.tsx +++ b/apps/web/src/components/timeline/AllocationPopover.tsx @@ -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 | 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(null); const [startDate, setStartDate] = useState(""); @@ -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 = ( -
+
Loading...
); return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body); } + if (allocationError) { + const errorPopover = ( +
+
Allocation unavailable
+

+ The selected booking could not be loaded right now. +

+

{allocationError.message}

+ +
+ ); + return typeof document === "undefined" ? errorPopover : createPortal(errorPopover, document.body); + } + if (!allocation) { const missingPopover = (
Allocation unavailable
@@ -160,6 +196,8 @@ export function AllocationPopover({
{/* Header */} diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index 62fa76b..c6422ad 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -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, ); diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index 6c9870c..a6cad4e 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -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, );