fix(timeline): harden project view interactions

This commit is contained in:
2026-04-01 14:10:28 +02:00
parent e103174d39
commit fa5e654739
3 changed files with 457 additions and 35 deletions
+446 -33
View File
@@ -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<TimelineSegmentScenario>(`
const availability = {
@@ -184,6 +192,96 @@ function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario
`);
}
function createTimelineDemandScenario(suffix: string): TimelineDemandScenario {
return runDbJson<TimelineDemandScenario>(`
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<null>(`
await prisma.assignment.deleteMany({
@@ -202,6 +300,28 @@ function cleanupTimelineSegmentScenario(projectId: string, resourceId: string) {
`);
}
function cleanupTimelineDemandScenario(projectId: string, resourceId: string) {
runDbJson<null>(`
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<Array<{ id: string; startDate: string; endDate: string }>>(`
const assignments = await prisma.assignment.findMany({
@@ -220,6 +340,26 @@ function listScenarioAssignments(projectId: string) {
`);
}
function listScenarioDemands(projectId: string) {
return runDbJson<Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>>(`
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<string, unknown>) : {};
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);
}
});
});
@@ -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({
<button
type="button"
onClick={() => onViewModeChange("resource")}
aria-pressed={viewMode === "resource"}
disabled={isViewModeSwitchDisabled}
title={isViewModeSwitchDisabled ? "Timeline is still loading" : undefined}
className={clsx(
"px-3 py-2 transition-colors",
"px-3 py-2 transition-colors disabled:cursor-wait disabled:opacity-60",
viewMode === "resource"
? "bg-brand-600 text-white"
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
@@ -191,8 +196,11 @@ export function TimelineToolbar({
<button
type="button"
onClick={() => onViewModeChange("project")}
aria-pressed={viewMode === "project"}
disabled={isViewModeSwitchDisabled}
title={isViewModeSwitchDisabled ? "Timeline is still loading" : undefined}
className={clsx(
"border-l border-gray-300 px-3 py-2 transition-colors dark:border-gray-600",
"border-l border-gray-300 px-3 py-2 transition-colors disabled:cursor-wait disabled:opacity-60 dark:border-gray-600",
viewMode === "project"
? "bg-brand-600 text-white"
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
@@ -657,6 +657,7 @@ function TimelineViewContent({
<TimelineToolbar
viewMode={viewMode}
onViewModeChange={setViewMode}
isViewModeSwitchDisabled={isInitialLoading}
filters={filters}
onFiltersChange={setFilters}
filterOpen={filterOpen}