From fa5e65473958f68654fc19b3fd395bd1f6516b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 14:10:28 +0200 Subject: [PATCH] fix(timeline): harden project view interactions --- apps/web/e2e/timeline.spec.ts | 479 ++++++++++++++++-- .../components/timeline/TimelineToolbar.tsx | 12 +- .../src/components/timeline/TimelineView.tsx | 1 + 3 files changed, 457 insertions(+), 35 deletions(-) diff --git a/apps/web/e2e/timeline.spec.ts b/apps/web/e2e/timeline.spec.ts index 8bb280d..afa90cc 100644 --- a/apps/web/e2e/timeline.spec.ts +++ b/apps/web/e2e/timeline.spec.ts @@ -109,6 +109,14 @@ type TimelineSegmentScenario = { resourceEid: string; }; +type TimelineDemandScenario = { + demandId: string; + projectId: string; + projectName: string; + projectShortCode: string; + resourceId: string; +}; + function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario { return runDbJson(` const availability = { @@ -184,6 +192,96 @@ function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario `); } +function createTimelineDemandScenario(suffix: string): TimelineDemandScenario { + return runDbJson(` + const availability = { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }; + + const resource = await prisma.resource.create({ + data: { + eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)}, + displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)}, + email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@capakraken.dev`)}, + chapter: "E2E", + lcrCents: 5000, + ucrCents: 9000, + availability, + skills: [], + dynamicFields: {}, + resourceType: "EMPLOYEE", + chgResponsibility: true, + rolledOff: false, + departed: false, + fte: 1, + }, + select: { id: true }, + }); + + const project = await prisma.project.create({ + data: { + shortCode: ${JSON.stringify(`E2EDM${suffix.slice(-6).toUpperCase()}`)}, + name: ${JSON.stringify(`E2E Timeline Demand ${suffix}`)}, + orderType: "CHARGEABLE", + allocationType: "EXT", + budgetCents: 1500000, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-30T00:00:00.000Z"), + status: "ACTIVE", + staffingReqs: [], + dynamicFields: {}, + }, + select: { id: true, name: true, shortCode: true }, + }); + + const demand = await prisma.demandRequirement.create({ + data: { + projectId: project.id, + startDate: new Date("2026-04-07T00:00:00.000Z"), + endDate: new Date("2026-04-16T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 100, + role: "E2E Open Demand", + headcount: 2, + budgetCents: 240000, + status: "PROPOSED", + metadata: {}, + }, + select: { id: true }, + }); + + await prisma.assignment.create({ + data: { + resourceId: resource.id, + projectId: project.id, + startDate: new Date("2026-04-08T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + hoursPerDay: 4, + percentage: 50, + role: "E2E Seeded Assignment", + dailyCostCents: 20000, + status: "ACTIVE", + metadata: {}, + }, + select: { id: true }, + }); + + console.log(JSON.stringify({ + demandId: demand.id, + projectId: project.id, + projectName: project.name, + projectShortCode: project.shortCode, + resourceId: resource.id, + })); + `); +} + function cleanupTimelineSegmentScenario(projectId: string, resourceId: string) { runDbJson(` await prisma.assignment.deleteMany({ @@ -202,6 +300,28 @@ function cleanupTimelineSegmentScenario(projectId: string, resourceId: string) { `); } +function cleanupTimelineDemandScenario(projectId: string, resourceId: string) { + runDbJson(` + await prisma.assignment.deleteMany({ + where: { projectId: ${JSON.stringify(projectId)} }, + }); + + await prisma.demandRequirement.deleteMany({ + where: { projectId: ${JSON.stringify(projectId)} }, + }); + + await prisma.project.deleteMany({ + where: { id: ${JSON.stringify(projectId)} }, + }); + + await prisma.resource.deleteMany({ + where: { id: ${JSON.stringify(resourceId)} }, + }); + + console.log("null"); + `); +} + function listScenarioAssignments(projectId: string) { return runDbJson>(` const assignments = await prisma.assignment.findMany({ @@ -220,6 +340,26 @@ function listScenarioAssignments(projectId: string) { `); } +function listScenarioDemands(projectId: string) { + return runDbJson>(` + const demands = await prisma.demandRequirement.findMany({ + where: { projectId: ${JSON.stringify(projectId)} }, + orderBy: [{ startDate: "asc" }, { endDate: "asc" }], + select: { id: true, startDate: true, endDate: true, headcount: true, status: true }, + }); + + console.log(JSON.stringify( + demands.map((entry) => ({ + id: entry.id, + startDate: entry.startDate.toISOString().slice(0, 10), + endDate: entry.endDate.toISOString().slice(0, 10), + headcount: entry.headcount, + status: entry.status, + })), + )); + `); +} + function readScenarioSnapshot(projectId: string, resourceId: string, resourceEid: string) { return runDbJson<{ resource: { id: string; eid: string; displayName: string } | null; @@ -665,12 +805,65 @@ async function measureAllocationResizeStartGap(page: Page, locatorString: string }; } +async function switchToProjectView(page: Page, readySelector?: string) { + await page.getByRole("button", { name: "Project view" }).click(); + if (readySelector) { + await expect(page.locator(readySelector).first()).toBeVisible(); + } else { + await expect + .poll(async () => { + const projectRows = await page.getByTestId("timeline-project-resource-row-canvas").count(); + const projectBars = await page.locator("[data-timeline-entry-type='project-bar']").count(); + const demandBars = await page.locator("[data-timeline-entry-type='demand']").count(); + const emptyStates = await page.getByText(/No projects in this time range/).count(); + return projectRows + projectBars + demandBars + emptyStates; + }, { timeout: 10_000 }) + .not.toBe(0); + } + await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(0); +} + +async function switchToResourceView(page: Page, readySelector?: string) { + await page.getByRole("button", { name: "Resource view" }).click(); + if (readySelector) { + await expect(page.locator(readySelector).first()).toBeVisible(); + } else { + await expect(page.getByTestId("timeline-resource-row-canvas").first()).toBeVisible(); + } + await expect(page.getByTestId("timeline-project-resource-row-canvas")).toHaveCount(0); +} + +async function ensureOpenDemandVisibilityEnabled(page: Page) { + await page.evaluate(() => { + const raw = window.localStorage.getItem("capakraken_prefs"); + const parsed = raw ? (JSON.parse(raw) as Record) : {}; + window.localStorage.setItem( + "capakraken_prefs", + JSON.stringify({ + ...parsed, + showDemandProjects: true, + }), + ); + }); + await page.reload({ waitUntil: "domcontentloaded" }); +} + test.describe("Timeline", () => { test.describe.configure({ mode: "serial" }); test.beforeEach(async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" })); + localStorage.setItem( + "capakraken_prefs", + JSON.stringify({ + hideCompletedProjects: true, + timelineDisplayMode: "strip", + heatmapColorScheme: "green-red", + showDemandProjects: true, + blinkOverbookedDays: false, + }), + ); }); await signInAsAdmin(page); await page.goto("/timeline"); @@ -687,14 +880,49 @@ test.describe("Timeline", () => { }); test("can switch between resource and project view", async ({ page }) => { - await page.click("text=Project view"); + await switchToProjectView(page); await expect( page.locator("text=0 projects").or(page.locator("text=/\\d+ projects/")), ).toBeVisible(); - await page.click("text=Resource view"); + await switchToResourceView(page); await expect(page.locator("text=/\\d+ resources/")).toBeVisible(); }); + test("view toggle stays disabled until the initial timeline load becomes interactive", async ({ page }) => { + 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=31&eids=${scenario.resourceEid}`, + { waitUntil: "domcontentloaded" }, + ); + + const projectButton = page.getByRole("button", { name: "Project view" }); + const resourceButton = page.getByRole("button", { name: "Resource view" }); + const resourceRowSelector = + `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`; + const projectRowSelector = + `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`; + + await expect(projectButton).toBeDisabled(); + await expect(resourceButton).toBeDisabled(); + + await expect(page.locator(resourceRowSelector)).toBeVisible(); + await expect(projectButton).toBeEnabled(); + await expect(resourceButton).toBeEnabled(); + await expect(resourceButton).toHaveAttribute("aria-pressed", "true"); + + await projectButton.click(); + + await expect(page.locator(projectRowSelector)).toBeVisible(); + await expect(projectButton).toHaveAttribute("aria-pressed", "true"); + await expect(resourceButton).toHaveAttribute("aria-pressed", "false"); + } finally { + cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); + } + }); + test("can navigate forward and back", async ({ page }) => { const todayBtn = page.locator("button", { hasText: "Today" }); await expect(todayBtn).toBeVisible(); @@ -786,9 +1014,7 @@ test.describe("Timeline", () => { await expect(allocationPopoverField).toBeVisible(); await page.mouse.click(40, 40); - await page.getByText("Project view").click(); - await expect(page.getByText(/projects/)).toBeVisible(); - await page.waitForTimeout(500); + await switchToProjectView(page); const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first(); const projectHoverBox = await projectHoverTarget.boundingBox(); @@ -837,8 +1063,7 @@ test.describe("Timeline", () => { }); test("right clicking a project header strip opens the project panel", async ({ page }) => { - await page.getByText("Project view").click(); - await expect(page.getByText(/projects/)).toBeVisible(); + await switchToProjectView(page); const projectBar = page .locator("[data-timeline-entry-type='project-bar'][data-timeline-project-id]") @@ -973,8 +1198,7 @@ test.describe("Timeline", () => { }); test("project bars stay attached to the pointer during fast drag", async ({ page }) => { - await page.getByText("Project view").click(); - await expect(page.getByText(/projects/)).toBeVisible(); + await switchToProjectView(page); await expect(page.locator("[data-timeline-entry-type='project-bar']").first()).toBeVisible(); const projectId = await findVisibleTimelineEntryId( @@ -1066,6 +1290,186 @@ test.describe("Timeline", () => { expect(result.leftEdgeGain).toBeGreaterThan(36); }); + test("project view resource allocations resize with a live preview before mouseup", 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 resourceRowSelector = + `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`; + const projectRowSelector = + `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`; + const projectAllocationSelector = + `${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`; + + await expect(page.locator(resourceRowSelector)).toBeVisible(); + await expect( + page.locator( + `[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`, + ).first(), + ).toBeVisible(); + + await switchToProjectView(page, projectRowSelector); + await expect(page.getByText(scenario.projectName).first()).toBeVisible(); + + const projectAllocation = page.locator(projectAllocationSelector).first(); + await expect(projectAllocation).toBeVisible(); + await expect + .poll(async () => + projectAllocation.evaluate((element) => ({ + segmentStart: element.getAttribute("data-allocation-segment-start"), + segmentEnd: element.getAttribute("data-allocation-segment-end"), + })), + ) + .toEqual({ segmentStart: null, segmentEnd: null }); + + const resizeEnd = await measureAllocationResizeGap(page, projectAllocationSelector); + expect(resizeEnd.widthGain).toBeGreaterThan(64); + expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48); + let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = []; + await expect + .poll(() => { + rightResizeAssignments = listScenarioAssignments(scenario.projectId); + if (rightResizeAssignments.length !== 1) { + return null; + } + + const [assignment] = rightResizeAssignments; + if (!assignment || assignment.id !== scenario.assignmentId) { + return null; + } + + return assignment.endDate; + }, { timeout: 15_000 }) + .not.toBe("2026-04-17"); + expect(rightResizeAssignments).toHaveLength(1); + expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId); + expect(rightResizeAssignments[0]?.startDate).toBe("2026-04-06"); + expect(rightResizeAssignments[0]?.endDate > "2026-04-17").toBe(true); + + const resizeStart = await measureAllocationResizeStartGap(page, projectAllocationSelector); + expect(resizeStart.widthGain).toBeGreaterThan(48); + expect(resizeStart.leftEdgeGain).toBeGreaterThan(36); + let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = []; + await expect + .poll(() => { + leftResizeAssignments = listScenarioAssignments(scenario.projectId); + if (leftResizeAssignments.length !== 1) { + return null; + } + + const [assignment] = leftResizeAssignments; + if (!assignment || assignment.id !== scenario.assignmentId) { + return null; + } + + return assignment.startDate; + }, { timeout: 15_000 }) + .not.toBe("2026-04-06"); + expect(leftResizeAssignments).toHaveLength(1); + expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId); + expect(leftResizeAssignments[0]?.startDate < "2026-04-06").toBe(true); + expect(leftResizeAssignments[0]?.endDate).toBe(rightResizeAssignments[0]?.endDate); + } finally { + cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); + } + }); + + test("project view demand bars resize with a live preview before mouseup", async ({ page }) => { + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + const scenario = createTimelineDemandScenario(suffix); + + try { + await page.goto( + `/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, + { waitUntil: "domcontentloaded" }, + ); + await ensureOpenDemandVisibilityEnabled(page); + const demandRowSelector = + `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`; + const demandSelector = + `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`; + + await switchToProjectView(page, demandRowSelector); + await expect(page.getByText(scenario.projectName).first()).toBeVisible(); + await expect(page.locator(demandSelector)).toBeVisible(); + + const resizeEnd = await measureAllocationResizeGap(page, demandSelector); + expect(resizeEnd.widthGain).toBeGreaterThan(48); + expect(resizeEnd.rightEdgeGain).toBeGreaterThan(36); + + let rightResizeDemands: Array<{ + id: string; + startDate: string; + endDate: string; + headcount: number; + status: string; + }> = []; + await expect + .poll(() => { + rightResizeDemands = listScenarioDemands(scenario.projectId); + if (rightResizeDemands.length !== 1) { + return null; + } + + const [demand] = rightResizeDemands; + if (!demand || demand.id !== scenario.demandId) { + return null; + } + + return demand.endDate; + }, { timeout: 15_000 }) + .not.toBe("2026-04-16"); + expect(rightResizeDemands).toHaveLength(1); + expect(rightResizeDemands[0]?.id).toBe(scenario.demandId); + expect(rightResizeDemands[0]?.startDate).toBe("2026-04-07"); + expect(rightResizeDemands[0]?.endDate > "2026-04-16").toBe(true); + expect(rightResizeDemands[0]?.headcount).toBe(2); + expect(rightResizeDemands[0]?.status).toBe("PROPOSED"); + + const resizeStart = await measureAllocationResizeStartGap(page, demandSelector); + expect(resizeStart.widthGain).toBeGreaterThan(36); + expect(resizeStart.leftEdgeGain).toBeGreaterThan(24); + + let leftResizeDemands: Array<{ + id: string; + startDate: string; + endDate: string; + headcount: number; + status: string; + }> = []; + await expect + .poll(() => { + leftResizeDemands = listScenarioDemands(scenario.projectId); + if (leftResizeDemands.length !== 1) { + return null; + } + + const [demand] = leftResizeDemands; + if (!demand || demand.id !== scenario.demandId) { + return null; + } + + return demand.startDate; + }, { timeout: 15_000 }) + .not.toBe("2026-04-07"); + expect(leftResizeDemands).toHaveLength(1); + expect(leftResizeDemands[0]?.id).toBe(scenario.demandId); + expect(leftResizeDemands[0]?.startDate < "2026-04-07").toBe(true); + expect(leftResizeDemands[0]?.endDate).toBe(rightResizeDemands[0]?.endDate); + expect(leftResizeDemands[0]?.headcount).toBe(2); + expect(leftResizeDemands[0]?.status).toBe("PROPOSED"); + } finally { + cleanupTimelineDemandScenario(scenario.projectId, scenario.resourceId); + } + }); + test("resource timeline supports resizing, moving, carving, and recreating segmented allocations with persisted dates", async ({ page, }) => { @@ -1537,8 +1941,9 @@ test.describe("Timeline", () => { ).first(); await expect(mondaySegment).toBeVisible(); - await page.getByText("Project view").click(); - await expect(page.getByText(/projects/)).toBeVisible(); + const projectRowSelector = + `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`; + await switchToProjectView(page, projectRowSelector); let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null; await expect @@ -1551,9 +1956,7 @@ test.describe("Timeline", () => { }) .not.toBeNull(); - const projectRow = page.locator( - `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`, - ).first(); + const projectRow = page.locator(projectRowSelector).first(); await expect(projectRow).toBeVisible(); const projectAllocation = projectRow.locator( @@ -1572,7 +1975,7 @@ test.describe("Timeline", () => { await popover.getByRole("button", { name: "Cancel" }).click(); await page.reload({ waitUntil: "domcontentloaded" }); - await page.getByText("Project view").click(); + await switchToProjectView(page, projectRowSelector); await expect(page.getByText(scenario.projectName).first()).toBeVisible(); const projectAllocationAfterReload = page @@ -1593,26 +1996,36 @@ test.describe("Timeline", () => { }); test("project view demand bars expose hover details", async ({ page }) => { - await page.getByText("Project view").click(); - await expect(page.getByText(/projects/)).toBeVisible(); - await expect(page.locator("[data-timeline-entry-type='demand']").first()).toBeVisible(); + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + const scenario = createTimelineDemandScenario(suffix); - const demandId = await findVisibleTimelineEntryId( - page, - "[data-timeline-entry-type='demand'][data-allocation-id]", - 72, - ); - expect(demandId).toBeTruthy(); + try { + await page.goto( + `/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, + { waitUntil: "domcontentloaded" }, + ); + await ensureOpenDemandVisibilityEnabled(page); + const demandRowSelector = + `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`; + const demandSelector = + `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`; - const demandBar = page.locator( - `[data-timeline-entry-type='demand'][data-allocation-id='${demandId}']`, - ); - await demandBar.hover(); + await switchToProjectView(page, demandRowSelector); + await expect(page.locator(demandSelector)).toBeVisible(); - const demandTooltip = page.getByTestId("timeline-demand-tooltip"); - await expect(demandTooltip).toBeVisible(); - await expect(demandTooltip).toContainText("Requested"); - await expect(demandTooltip).toContainText("Open"); - await expect(demandTooltip).toContainText("Click for details and actions"); + const demandBar = page.locator(demandSelector); + await demandBar.hover(); + + const demandTooltip = page.getByTestId("timeline-demand-tooltip"); + await expect(demandTooltip).toBeVisible(); + await expect(demandTooltip).toContainText("E2E Open Demand"); + await expect(demandTooltip).toContainText(scenario.projectShortCode); + await expect(demandTooltip).toContainText("Requested"); + await expect(demandTooltip).toContainText("2 seats"); + await expect(demandTooltip).toContainText("Open"); + await expect(demandTooltip).toContainText("Click for details and actions"); + } finally { + cleanupTimelineDemandScenario(scenario.projectId, scenario.resourceId); + } }); }); diff --git a/apps/web/src/components/timeline/TimelineToolbar.tsx b/apps/web/src/components/timeline/TimelineToolbar.tsx index b689854..d77200e 100644 --- a/apps/web/src/components/timeline/TimelineToolbar.tsx +++ b/apps/web/src/components/timeline/TimelineToolbar.tsx @@ -11,6 +11,7 @@ import { TimelineQuickFilters } from "./TimelineQuickFilters.js"; interface TimelineToolbarProps { viewMode: "resource" | "project"; onViewModeChange: (mode: "resource" | "project") => void; + isViewModeSwitchDisabled?: boolean; filters: TimelineFilters; onFiltersChange: (f: TimelineFilters) => void; filterOpen: boolean; @@ -30,6 +31,7 @@ interface TimelineToolbarProps { export function TimelineToolbar({ viewMode, onViewModeChange, + isViewModeSwitchDisabled = false, filters, onFiltersChange, filterOpen, @@ -179,8 +181,11 @@ export function TimelineToolbar({