Files
CapaKraken/apps/web/e2e/timeline.spec.ts

2125 lines
78 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
const PLAYWRIGHT_RUNTIME_ENV_PATH = resolve(process.cwd(), "e2e/.playwright-runtime.json");
const WEB_ENV_PATHS = [
resolve(process.cwd(), ".env.local"),
resolve(process.cwd(), "../../.env.local"),
resolve(process.cwd(), "../../.env"),
];
function resolveDatabaseUrl(): string {
const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"];
if (explicitPlaywrightUrl) {
return explicitPlaywrightUrl;
}
if (existsSync(PLAYWRIGHT_RUNTIME_ENV_PATH)) {
try {
const runtimeEnv = JSON.parse(readFileSync(PLAYWRIGHT_RUNTIME_ENV_PATH, "utf8")) as {
PLAYWRIGHT_DATABASE_URL?: string;
DATABASE_URL?: string;
};
if (runtimeEnv.PLAYWRIGHT_DATABASE_URL) {
return runtimeEnv.PLAYWRIGHT_DATABASE_URL;
}
if (runtimeEnv.DATABASE_URL) {
return runtimeEnv.DATABASE_URL;
}
} catch {
// Fall back to env files if the runtime file is temporarily unavailable.
}
}
for (const envPath of WEB_ENV_PATHS) {
if (!existsSync(envPath)) {
continue;
}
const envFile = readFileSync(envPath, "utf8");
for (const rawLine of envFile.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) {
return line.slice("PLAYWRIGHT_DATABASE_URL=".length);
}
if (line.startsWith("DATABASE_URL_TEST=")) {
return line.slice("DATABASE_URL_TEST=".length);
}
if (line.startsWith("DATABASE_URL=")) {
return line.slice("DATABASE_URL=".length);
}
}
}
const fallbackTestUrl = process.env["DATABASE_URL_TEST"];
if (fallbackTestUrl) {
return fallbackTestUrl;
}
const fallback = process.env["DATABASE_URL"];
if (fallback) {
return fallback;
}
throw new Error("DATABASE_URL is not available for the Playwright timeline test.");
}
function runDbJson<T>(body: string): T {
const script = `
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
try {
${body}
} finally {
await prisma.$disconnect();
}
`;
const output = execFileSync("node", ["--input-type=module", "-e", script], {
cwd: DB_WORKDIR,
env: {
...process.env,
DATABASE_URL: resolveDatabaseUrl(),
},
encoding: "utf8",
}).trim();
return JSON.parse(output) as T;
}
type TimelineSegmentScenario = {
assignmentId: string;
projectId: string;
projectName: string;
projectShortCode: string;
resourceId: string;
resourceEid: string;
};
type TimelineDemandScenario = {
demandId: string;
projectId: string;
projectName: string;
projectShortCode: string;
resourceId: string;
};
function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario {
return runDbJson<TimelineSegmentScenario>(`
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.${suffix}`)},
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
email: ${JSON.stringify(`e2e.timeline.${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, eid: true },
});
const project = await prisma.project.create({
data: {
shortCode: ${JSON.stringify(`E2ETL${suffix.slice(-6).toUpperCase()}`)},
name: ${JSON.stringify(`E2E Timeline Project ${suffix}`)},
orderType: "CHARGEABLE",
allocationType: "EXT",
budgetCents: 2500000,
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 assignment = await prisma.assignment.create({
data: {
resourceId: resource.id,
projectId: project.id,
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "E2E Role",
dailyCostCents: 40000,
status: "ACTIVE",
metadata: {},
},
select: { id: true },
});
console.log(JSON.stringify({
assignmentId: assignment.id,
projectId: project.id,
projectName: project.name,
projectShortCode: project.shortCode,
resourceId: resource.id,
resourceEid: resource.eid,
}));
`);
}
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({
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 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({
where: { projectId: ${JSON.stringify(projectId)} },
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
select: { id: true, startDate: true, endDate: true },
});
console.log(JSON.stringify(
assignments.map((entry) => ({
id: entry.id,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
})),
));
`);
}
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;
project: { id: string; name: string; shortCode: string } | null;
assignments: Array<{ id: string; startDate: string; endDate: string; status: string }>;
}>(`
const [resource, project, assignments] = await Promise.all([
prisma.resource.findUnique({
where: { id: ${JSON.stringify(resourceId)} },
select: { id: true, eid: true, displayName: true },
}),
prisma.project.findUnique({
where: { id: ${JSON.stringify(projectId)} },
select: { id: true, name: true, shortCode: true },
}),
prisma.assignment.findMany({
where: {
projectId: ${JSON.stringify(projectId)},
resourceId: ${JSON.stringify(resourceId)},
},
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
select: { id: true, startDate: true, endDate: true, status: true },
}),
]);
console.log(JSON.stringify({
resource: resource && resource.eid === ${JSON.stringify(resourceEid)}
? resource
: resource,
project,
assignments: assignments.map((entry) => ({
id: entry.id,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
status: entry.status,
})),
}));
`);
}
async function waitForScenarioAssignments(
projectId: string,
expected: Array<{ startDate: string; endDate: string }>,
) {
await expect
.poll(
() =>
listScenarioAssignments(projectId).map((entry) => ({
startDate: entry.startDate,
endDate: entry.endDate,
})),
{ timeout: 15000 },
)
.toEqual(expected);
}
async function dragLocatorBy(page: Page, locator: ReturnType<Page["locator"]>, deltaX: number) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Expected locator to be visible before dragging");
}
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + deltaX, startY, { steps: 8 });
await page.waitForTimeout(80);
return box;
}
async function openAllocationContextMenuAtOffset(
page: Page,
locator: ReturnType<Page["locator"]>,
xOffset: number,
) {
const target = await resolveAllocationContextMenuTarget(locator);
const box = await readBoundingBox(target);
await page.mouse.click(
box.x + Math.min(Math.max(xOffset, 2), Math.max(box.width - 2, 2)),
box.y + box.height / 2,
{ button: "right" },
);
}
async function openContextMenuAtCenter(
page: Page,
locator: ReturnType<Page["locator"]>,
) {
const target = await resolveAllocationContextMenuTarget(locator);
const box = await readBoundingBox(target);
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);
}
async function selectProjectFromCombobox(
page: Page,
projectShortCode: string,
projectName: string,
) {
const projectInput = page.locator('input[placeholder="Search project…"]').last();
await projectInput.click();
await projectInput.fill(projectShortCode);
const optionName = new RegExp(
`${escapeRegex(projectShortCode)}\\s*.*${escapeRegex(projectName)}`,
"i",
);
await page.getByRole("button", { name: optionName }).last().click();
}
async function dragRowSelection(
page: Page,
row: ReturnType<Page["locator"]>,
startX: number,
endX: number,
) {
const rowBox = await readBoundingBox(row);
const centerY = rowBox.y + Math.min(rowBox.height / 2, rowBox.height - 6);
await page.mouse.move(startX, centerY);
await page.mouse.down();
await page.mouse.move(endX, centerY, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(120);
}
async function readBoundingBox(locator: ReturnType<Page["locator"]>) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Expected locator to have a bounding box");
}
return box;
}
async function resolveAllocationContextMenuTarget(locator: ReturnType<Page["locator"]>) {
const interactionTarget = locator.locator("[data-allocation-interaction='body']").first();
return (await interactionTarget.count()) > 0 ? interactionTarget : locator;
}
async function listRenderedAllocationSegments(
row: ReturnType<Page["locator"]>,
allocationId?: string,
) {
const selector = allocationId
? `[data-allocation-id="${allocationId}"]`
: "[data-allocation-id]";
return row.locator(selector).evaluateAll((elements) =>
elements.map((element) => {
const htmlElement = element as HTMLElement;
const { dataset } = htmlElement;
return {
allocationId: dataset.allocationId ?? null,
segmentIndex: dataset.allocationSegmentIndex ?? null,
segmentStart: dataset.allocationSegmentStart ?? null,
segmentEnd: dataset.allocationSegmentEnd ?? null,
interaction: dataset.allocationInteraction ?? null,
text: htmlElement.innerText.replace(/\s+/gu, " ").trim(),
};
}),
);
}
function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
}
async function findVisibleTimelineEntryId(
page: Page,
selector: string,
minimumWidth = 24,
) {
return page.locator(selector).evaluateAll((elements, minimum) => {
for (const element of elements) {
if (!(element instanceof HTMLElement)) continue;
const id = element.dataset.allocationId ?? element.dataset.timelineProjectId;
if (!id) continue;
const rect = element.getBoundingClientRect();
const mostlyVisible =
rect.width >= minimum &&
rect.height >= 12 &&
rect.right > 96 &&
rect.left < window.innerWidth - 96 &&
rect.bottom > 0 &&
rect.top < window.innerHeight;
if (mostlyVisible) {
return id;
}
}
return null;
}, minimumWidth);
}
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 scrollContainer = document.querySelector<HTMLElement>(
"div.app-surface.relative.z-0.flex-1.overflow-auto",
);
const stickyHeaderBottom = scrollContainer
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
0,
)
: 0;
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
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 allocationId = element.dataset.allocationId;
if (!allocationId) continue;
const rect = element.getBoundingClientRect();
const isVisible =
rect.width >= 24 &&
rect.height >= 10 &&
rect.right > 48 &&
rect.left < window.innerWidth - 48 &&
rect.bottom > safeTop &&
rect.top < window.innerHeight;
if (!isVisible) {
continue;
}
fallback ??= {
allocationId,
segmentStart: element.dataset.allocationSegmentStart ?? null,
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
};
const isPreferred =
rect.width >= 36 &&
rect.width <= 220 &&
rect.left >= 48 &&
rect.right <= window.innerWidth - 48 &&
rect.top >= safeTop;
if (!isPreferred) {
continue;
}
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({
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] ?? fallback;
});
}
async function measureProjectBarDragGap(page: Page, locatorString: string) {
const bar = page.locator(locatorString);
await bar.scrollIntoViewIfNeeded();
const box = await bar.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Expected a visible project bar for drag measurement");
}
const anchorOffsetX = Math.min(Math.max(box.width * 0.35, 16), box.width - 16);
const pointerY = box.y + box.height / 2;
const startX = box.x + anchorOffsetX;
const dragDistance = 132;
const gaps: number[] = [];
const lefts: number[] = [];
await page.mouse.move(startX, pointerY);
await page.mouse.down();
for (let step = 1; step <= 8; step += 1) {
const targetX = startX + (dragDistance * step) / 8;
await page.mouse.move(targetX, pointerY, { steps: 1 });
await page.waitForTimeout(16);
const currentBox = await bar.boundingBox();
if (!currentBox) continue;
lefts.push(currentBox.x);
gaps.push(Math.abs(currentBox.x - (targetX - anchorOffsetX)));
}
await page.mouse.up();
return {
maxGap: Math.max(...gaps),
movedDistance: Math.max(...lefts) - box.x,
};
}
async function measureAllocationDragGap(page: Page, locatorString: string) {
const bar = page.locator(locatorString);
await bar.scrollIntoViewIfNeeded();
const box = await bar.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Expected a visible allocation bar for drag measurement");
}
const anchorOffsetX = Math.min(Math.max(box.width / 2, 18), box.width - 18);
const pointerY = box.y + box.height / 2;
const startX = box.x + anchorOffsetX;
const dragDistance = 108;
const gaps: number[] = [];
const lefts: number[] = [];
await page.mouse.move(startX, pointerY);
await page.mouse.down();
for (let step = 1; step <= 8; step += 1) {
const targetX = startX + (dragDistance * step) / 8;
await page.mouse.move(targetX, pointerY, { steps: 1 });
await page.waitForTimeout(16);
const currentBox = await bar.boundingBox();
if (!currentBox) continue;
lefts.push(currentBox.x);
gaps.push(Math.abs(currentBox.x - (targetX - anchorOffsetX)));
}
await page.mouse.up();
return {
maxGap: Math.max(...gaps),
movedDistance: Math.max(...lefts) - box.x,
};
}
async function measureAllocationResizeGap(page: Page, locatorString: string) {
const bar = page.locator(locatorString);
await bar.scrollIntoViewIfNeeded();
const box = await bar.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Expected a visible allocation bar for resize measurement");
}
const pointerY = box.y + box.height / 2;
const startX = box.x + box.width - 3;
const resizeDistance = 96;
const widths: number[] = [];
const rightEdges: number[] = [];
await page.mouse.move(startX, pointerY);
await page.mouse.down();
for (let step = 1; step <= 8; step += 1) {
const targetX = startX + (resizeDistance * step) / 8;
await page.mouse.move(targetX, pointerY, { steps: 1 });
await page.waitForTimeout(16);
const currentBox = await bar.boundingBox();
if (!currentBox) continue;
widths.push(currentBox.width);
rightEdges.push(currentBox.x + currentBox.width);
}
await page.mouse.up();
return {
widthGain: Math.max(...widths) - box.width,
rightEdgeGain: Math.max(...rightEdges) - (box.x + box.width),
};
}
async function measureAllocationResizeStartGap(page: Page, locatorString: string) {
const bar = page.locator(locatorString);
await bar.scrollIntoViewIfNeeded();
const box = await bar.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Expected a visible allocation bar for resize-start measurement");
}
const pointerY = box.y + box.height / 2;
const startX = box.x + 3;
const resizeDistance = 72;
const widths: number[] = [];
const leftEdges: number[] = [];
await page.mouse.move(startX, pointerY);
await page.mouse.down();
for (let step = 1; step <= 8; step += 1) {
const targetX = startX - (resizeDistance * step) / 8;
await page.mouse.move(targetX, pointerY, { steps: 1 });
await page.waitForTimeout(16);
const currentBox = await bar.boundingBox();
if (!currentBox) continue;
widths.push(currentBox.width);
leftEdges.push(currentBox.x);
}
await page.mouse.up();
return {
widthGain: Math.max(...widths) - box.width,
leftEdgeGain: box.x - Math.min(...leftEdges),
};
}
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");
});
test("loads and displays the timeline", async ({ page }) => {
await expect(page.locator("text=Resource view")).toBeVisible();
await expect(page.locator("text=Project view")).toBeVisible();
await expect(page.getByRole("button", { name: /All Clients|Clients:/ })).toBeVisible();
await expect(page.getByRole("button", { name: /All Chapters|Chapters:/ })).toBeVisible();
await expect(page.getByRole("button", { name: /All people|People:/ })).toBeVisible();
// Timeline canvas should be visible
await expect(page.locator("div.app-surface.relative.flex-1.overflow-auto")).toBeVisible();
});
test("can switch between resource and project view", async ({ page }) => {
await switchToProjectView(page);
await expect(
page.locator("text=0 projects").or(page.locator("text=/\\d+ projects/")),
).toBeVisible();
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();
await page.locator("button", { hasText: "" }).click();
await page.locator("button", { hasText: "" }).click();
await todayBtn.click();
});
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
await page.goto("/allocations");
await expect(
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
).toBeVisible({ timeout: 10000 });
await page.locator('nav a >> text="Timeline"').first().click();
await expect(page).toHaveURL(/\/timeline/);
await expect(
page.locator(".app-toolbar").getByText(/[1-9]\d* resources · [1-9]\d* allocations/),
).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("timeline-resource-row-canvas").first()).toBeVisible({
timeout: 10000,
});
await expect(page.locator("text=No allocations in this time range")).not.toBeVisible();
});
test("filter panel opens and closes", async ({ page }) => {
await page.locator("button", { hasText: "Filter" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await expect(page.getByPlaceholder("Search projects…")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("heading", { name: "Filters" })).not.toBeVisible();
});
test("shows placeholder bars for unassigned allocations", async ({ page }) => {
// Filter to show placeholders (enabled by default)
// The timeline should have at least one dashed placeholder bar from seed data
await page.waitForSelector(".overflow-auto", { state: "visible" });
// Check that the timeline loaded (resource rows or empty state visible)
await expect(
page.locator(".app-toolbar").getByText(/\d+ resources · \d+ allocations/),
).toBeVisible();
});
test("clicking a placeholder opens the fill placeholder modal", async ({ page }) => {
// Wait for timeline to load
await page.waitForSelector(".overflow-auto");
await page.waitForTimeout(1000); // let tRPC queries settle
// Try to find and click a placeholder bar (dashed border style)
const placeholderBar = page.locator("[style*='dashed']").first();
if ((await placeholderBar.count()) > 0) {
await placeholderBar.click();
await expect(
page.locator("text=Fill Placeholder").or(page.locator("text=Assign Resource")),
).toBeVisible();
await page.keyboard.press("Escape");
}
});
test("resource and project views keep tooltips opaque in dark mode and support right click", async ({
page,
}) => {
await page.waitForSelector(".overflow-auto", { state: "visible" });
await page.waitForTimeout(1000);
const heatmapTooltip = page
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-gray-800")
.first();
const allocationPopoverField = page.getByText("Hours / day");
const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first();
const resourceHoverBox = await resourceHoverTarget.boundingBox();
expect(resourceHoverBox).not.toBeNull();
if (!resourceHoverBox) {
throw new Error("Expected a resource timeline row canvas to be available");
}
await page.mouse.move(resourceHoverBox.x + 120, resourceHoverBox.y + 20);
await expect(heatmapTooltip).toBeVisible();
await expect
.poll(async () => {
return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor);
})
.toBe("rgba(3, 7, 18, 0.96)");
const resourceAllocation = page
.locator("[data-timeline-entry-type='allocation'][data-allocation-id]")
.first();
await resourceAllocation.click({ button: "right" });
await expect(allocationPopoverField).toBeVisible();
await page.mouse.click(40, 40);
await switchToProjectView(page);
const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first();
const projectHoverBox = await projectHoverTarget.boundingBox();
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
const projectAllocationBox = await projectAllocation.boundingBox();
expect(projectHoverBox).not.toBeNull();
expect(projectAllocationBox).not.toBeNull();
if (!projectHoverBox) {
throw new Error("Expected a project timeline row canvas to be available");
}
if (!projectAllocationBox) {
throw new Error("Expected a project allocation block to be available");
}
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
await expect(heatmapTooltip).toBeVisible();
await expect
.poll(async () => {
return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor);
})
.toBe("rgba(3, 7, 18, 0.96)");
await projectAllocation.click({ button: "right" });
await expect(allocationPopoverField).toBeVisible();
});
test("resource view right click resolves allocation popovers instead of getting stuck on loading", async ({
page,
}) => {
await page.waitForSelector(".overflow-auto", { state: "visible" });
await expect(
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocation = page
.locator("[data-timeline-entry-type='allocation'][data-allocation-id]")
.first();
await allocation.click({ button: "right" });
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 }) => {
await switchToProjectView(page);
const projectBar = page
.locator("[data-timeline-entry-type='project-bar'][data-timeline-project-id]")
.first();
await expect(projectBar).toBeVisible();
await projectBar.click({ button: "right" });
await expect(page.getByText("Project Details")).toBeVisible();
await expect(page.getByRole("heading", { level: 2 })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Project Details")).not.toBeVisible();
});
test("shows resolved holiday overlays in the resource timeline and exposes the holiday name in the tooltip", async ({
page,
}) => {
await page.goto("/timeline?startDate=2026-04-01&days=14&eids=bruce.banner", {
waitUntil: "domcontentloaded",
});
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
await expect(row).toBeVisible();
const holidayBlock = row.locator(
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
).first();
await expect(holidayBlock).toBeVisible();
const rowBox = await row.boundingBox();
const holidayBox = await holidayBlock.boundingBox();
expect(rowBox).not.toBeNull();
expect(holidayBox).not.toBeNull();
if (!rowBox || !holidayBox) {
throw new Error("Expected timeline row and holiday block bounding boxes to be available");
}
await row.hover({
position: {
x: holidayBox.x - rowBox.x + holidayBox.width / 2,
y: holidayBox.y - rowBox.y + Math.min(holidayBox.height / 2, rowBox.height - 4),
},
});
const holidayTooltip = page
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
.first();
await expect(holidayTooltip).toBeVisible();
await expect(holidayTooltip).toContainText("Karfreitag");
await expect(holidayTooltip).toContainText("3 April 2026");
});
test("allocation context popover stays interactive after pointer moves across the timeline canvas", async ({
page,
}) => {
await expect(
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationSegment).toBeTruthy();
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
await openContextMenuAtCenter(page, allocation);
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
await expect(cancelButton).toBeVisible();
const resourceRow = page.getByTestId("timeline-resource-row-canvas").first();
const resourceRowBox = await resourceRow.boundingBox();
expect(resourceRowBox).not.toBeNull();
if (!resourceRowBox) {
throw new Error("Expected a resource timeline row canvas to be available");
}
await page.mouse.move(resourceRowBox.x + 160, resourceRowBox.y + 20);
await expect(cancelButton).toBeVisible();
await cancelButton.click();
await expect(cancelButton).not.toBeVisible();
});
test("timeline overlays react predictably to viewport changes", async ({ page }) => {
const scrollContainer = page.locator("div.app-surface.relative.z-0.flex-1.overflow-auto");
await expect(scrollContainer).toBeVisible();
const resourceLabel = page.locator("[data-resource-hover-id]").first();
await resourceLabel.hover();
const hoverCard = page.locator("[data-resource-hover-card='true']");
await expect(hoverCard).toBeVisible();
const initialTop = await hoverCard.evaluate((element) => element.getBoundingClientRect().top);
await scrollContainer.evaluate((element) => {
element.scrollTop = Math.min(220, element.scrollHeight);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(hoverCard).toBeVisible();
await expect
.poll(async () => hoverCard.evaluate((element) => element.getBoundingClientRect().top))
.not.toBe(initialTop);
await page.mouse.move(8, 8);
await expect(hoverCard).not.toBeVisible();
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationSegment).toBeTruthy();
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
await openContextMenuAtCenter(page, allocation);
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
await expect(cancelButton).toBeVisible();
await scrollContainer.evaluate((element) => {
element.scrollTop = Math.min(element.scrollTop + 160, element.scrollHeight);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(cancelButton).not.toBeVisible();
});
test("timeline allocation popovers close cleanly across view switches", async ({ page }) => {
const allocation = page
.locator("[data-timeline-entry-type='allocation'][data-allocation-id]")
.first();
await expect(allocation).toBeVisible();
await openContextMenuAtCenter(page, allocation);
const allocationPopover = page.getByTestId("timeline-allocation-popover");
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, {
timeout: 2_000,
});
await expect(allocationPopover).toBeVisible();
await switchToProjectView(page);
await expect(allocationPopover).toHaveCount(0);
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
});
test("project bars stay attached to the pointer during fast drag", async ({ page }) => {
await switchToProjectView(page);
await expect(page.locator("[data-timeline-entry-type='project-bar']").first()).toBeVisible();
const projectId = await findVisibleTimelineEntryId(
page,
"[data-timeline-entry-type='project-bar'][data-timeline-project-id]",
24,
);
expect(projectId).toBeTruthy();
const result = await measureProjectBarDragGap(
page,
`[data-timeline-entry-type='project-bar'][data-timeline-project-id='${projectId}']`,
);
expect(result.movedDistance).toBeGreaterThan(72);
expect(result.maxGap).toBeLessThan(24);
});
test("resource allocation bars stay attached to the pointer during drag", async ({ page }) => {
await page.goto("/timeline?startDate=2026-04-01&days=31", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationSegment).toBeTruthy();
const result = await measureAllocationDragGap(
page,
allocationSegmentSelector(allocationSegment!),
);
expect(result.movedDistance).toBeGreaterThan(56);
expect(result.maxGap).toBeLessThan(24);
});
test("allocation resize shows a live preview before mouseup", async ({
page,
}) => {
await page.goto("/timeline?startDate=2026-04-01&days=31", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationSegment).toBeTruthy();
const result = await measureAllocationResizeGap(
page,
allocationSegmentSelector(allocationSegment!),
);
expect(result.widthGain).toBeGreaterThan(64);
expect(result.rightEdgeGain).toBeGreaterThan(48);
});
test("allocation resize cancels cleanly when the window loses focus mid-drag", async ({
page,
}) => {
await page.goto("/timeline?startDate=2026-04-01&days=31", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationSegment).toBeTruthy();
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
await allocation.scrollIntoViewIfNeeded();
const initialBox = await readBoundingBox(allocation);
const pointerY = initialBox.y + initialBox.height / 2;
const startX = initialBox.x + initialBox.width - 3;
await page.mouse.move(startX, pointerY);
await page.mouse.down();
await page.mouse.move(startX + 72, pointerY, { steps: 6 });
await expect
.poll(async () => {
const box = await allocation.boundingBox();
return box ? Math.round(box.width) : null;
})
.toBeGreaterThan(Math.round(initialBox.width + 36));
await page.evaluate(() => {
window.dispatchEvent(new Event("blur"));
});
await expect
.poll(async () => {
const box = await allocation.boundingBox();
return box ? Math.round(box.width) : null;
})
.toBe(Math.round(initialBox.width));
await page.mouse.up();
const secondResize = await measureAllocationResizeGap(
page,
allocationSegmentSelector(allocationSegment!),
);
expect(secondResize.widthGain).toBeGreaterThan(64);
expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
});
test("allocation start resize shows a live preview before mouseup", async ({
page,
}) => {
await page.goto("/timeline?startDate=2026-04-01&days=31", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
).toBeVisible();
const allocationSegment = await findVisibleAllocationSegmentForResize(
page,
"[data-timeline-entry-type='allocation'][data-allocation-id]",
);
expect(allocationSegment).toBeTruthy();
const result = await measureAllocationResizeStartGap(
page,
allocationSegmentSelector(allocationSegment!),
);
expect(result.widthGain).toBeGreaterThan(48);
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,
}) => {
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const scenario = createTimelineSegmentScenario(suffix);
const scenarioSnapshot = readScenarioSnapshot(
scenario.projectId,
scenario.resourceId,
scenario.resourceEid,
);
try {
expect(scenarioSnapshot.resource?.eid).toBe(scenario.resourceEid);
expect(scenarioSnapshot.project?.id).toBe(scenario.projectId);
expect(scenarioSnapshot.assignments).toEqual([
{
id: scenario.assignmentId,
startDate: "2026-04-06",
endDate: "2026-04-17",
status: "ACTIVE",
},
]);
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
waitUntil: "domcontentloaded",
});
const row = page
.locator(
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
)
.first();
await expect(row).toBeVisible();
const renderedSegments = await listRenderedAllocationSegments(row, scenario.assignmentId);
console.log(
`[timeline-e2e] rendered segments ${JSON.stringify({
resourceEid: scenario.resourceEid,
assignmentId: scenario.assignmentId,
renderedSegments,
})}`,
);
const baseSegment = row.locator(
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
);
await expect(baseSegment).toBeVisible();
await expect(baseSegment.locator('[data-allocation-handle="start"]')).toBeVisible();
await expect(baseSegment.locator('[data-allocation-handle="end"]')).toBeVisible();
const baseSegmentBox = await readBoundingBox(baseSegment);
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
expect(dayWidth).toBeGreaterThan(8);
await dragLocatorBy(page, baseSegment.locator('[data-allocation-handle="start"]'), dayWidth);
await releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-07", endDate: "2026-04-10" },
{ startDate: "2026-04-11", endDate: "2026-04-17" },
]);
const resizedSegment = row.locator(
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-07"][data-allocation-segment-end="2026-04-10"]`,
);
await expect(resizedSegment).toBeVisible();
await dragLocatorBy(page, resizedSegment.locator('[data-allocation-interaction="body"]'), -dayWidth);
await releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-06", endDate: "2026-04-09" },
{ startDate: "2026-04-11", endDate: "2026-04-17" },
]);
const movedSegment = row.locator(
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-09"]`,
);
await expect(movedSegment).toBeVisible();
await openAllocationContextMenuAtOffset(page, movedSegment, dayWidth * 1.5);
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
await expect(cancelButton).toBeVisible();
await cancelButton.click();
await expect(cancelButton).not.toBeVisible();
await dragLocatorBy(page, movedSegment.locator('[data-allocation-handle="end"]'), dayWidth);
await releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-06", endDate: "2026-04-10" },
{ startDate: "2026-04-11", endDate: "2026-04-17" },
]);
const restoredSegment = row.locator(
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
);
await expect(restoredSegment).toBeVisible();
await openAllocationContextMenuAtOffset(page, restoredSegment, dayWidth * 2.5);
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
await expect(carveDateInputs.nth(2)).toHaveValue("08/04/2026");
await expect(carveDateInputs.nth(3)).toHaveValue("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-10" },
{ startDate: "2026-04-11", endDate: "2026-04-17" },
]);
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
const nextWeekSegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
await expect(leftSplit).toBeVisible();
await expect(rightSplit).toBeVisible();
await expect(nextWeekSegment).toBeVisible();
const leftSplitBox = await readBoundingBox(leftSplit);
const rightSplitBox = await readBoundingBox(rightSplit);
const rowBox = await readBoundingBox(row);
const gapCenterX = (leftSplitBox.x + leftSplitBox.width + rightSplitBox.x) / 2;
const gapCenterY = rowBox.y + Math.min(rowBox.height / 2, rowBox.height - 6);
await page.mouse.move(gapCenterX, gapCenterY);
await page.mouse.down();
await page.mouse.up();
await expect(page.getByText("Assign to Project")).toBeVisible();
await selectProjectFromCombobox(page, scenario.projectShortCode, scenario.projectName);
await page.getByRole("button", { name: "Assign" }).click();
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-06", endDate: "2026-04-07" },
{ startDate: "2026-04-08", endDate: "2026-04-08" },
{ startDate: "2026-04-09", endDate: "2026-04-10" },
{ startDate: "2026-04-11", endDate: "2026-04-17" },
]);
await expect(
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
).toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
}
});
test("resource timeline persists multi-day carve and reverse recreation after reload", 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 row = page
.locator(
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
)
.first();
await expect(row).toBeVisible();
const baseSegment = row.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("09/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-10", endDate: "2026-04-17" },
]);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
const fridayBridge = row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first();
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
await expect(leftSplit).toBeVisible();
await expect(fridayBridge).toBeVisible();
await expect(mondaySegment).toBeVisible();
const fridayBridgeBox = await readBoundingBox(fridayBridge);
await dragRowSelection(
page,
row,
fridayBridgeBox.x - dayWidth / 2,
fridayBridgeBox.x - dayWidth * 1.5,
);
await expect(page.getByText("Assign to Project")).toBeVisible();
await selectProjectFromCombobox(page, scenario.projectShortCode, scenario.projectName);
await page.getByRole("button", { name: "Assign" }).click();
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-06", endDate: "2026-04-07" },
{ startDate: "2026-04-08", endDate: "2026-04-09" },
{ startDate: "2026-04-10", endDate: "2026-04-17" },
]);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]').first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
).toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
}
});
test("resource timeline narrow split fragments keep both handles and monday context dates", 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 row = page
.locator(
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
)
.first();
await expect(row).toBeVisible();
const baseSegment = row.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 expect(carveDateInputs.nth(2)).toHaveValue("08/04/2026");
await expect(carveDateInputs.nth(3)).toHaveValue("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 leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
await expect(leftSplit).toBeVisible();
await expect(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible();
await expect(leftSplit.locator('[data-allocation-handle="start"]')).toBeVisible();
await expect(leftSplit.locator('[data-allocation-handle="end"]')).toBeVisible();
await expect(rightSplit.locator('[data-allocation-handle="start"]')).toBeVisible();
await expect(rightSplit.locator('[data-allocation-handle="end"]')).toBeVisible();
await dragLocatorBy(page, leftSplit.locator('[data-allocation-handle="start"]'), dayWidth);
await releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-07", endDate: "2026-04-07" },
{ startDate: "2026-04-09", endDate: "2026-04-17" },
]);
const resizedRightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
await dragLocatorBy(page, resizedRightSplit.locator('[data-allocation-handle="end"]'), -dayWidth);
await releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-07", endDate: "2026-04-07" },
{ startDate: "2026-04-09", endDate: "2026-04-09" },
{ startDate: "2026-04-11", endDate: "2026-04-17" },
]);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
const mondaySegmentAfterReload = row.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
).first();
await expect(mondaySegmentAfterReload).toBeVisible();
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
const mondayCancelButton = page.getByRole("button", { name: "Cancel" }).last();
await openAllocationContextMenuAtOffset(page, mondaySegmentAfterReload, dayWidth * 0.5);
await expect(page.getByText("Loading...")).not.toBeVisible();
await expect(mondayCancelButton).toBeVisible();
await expect(mondayCarveDateInputs.nth(2)).toHaveValue("13/04/2026");
await expect(mondayCarveDateInputs.nth(3)).toHaveValue("13/04/2026");
await mondayCancelButton.click();
await expect(mondayCancelButton).not.toBeVisible();
await openAllocationContextMenuAtOffset(page, mondaySegmentAfterReload, dayWidth * 4.5);
await expect(page.getByText("Loading...")).not.toBeVisible();
await expect(mondayCancelButton).toBeVisible();
await expect(mondayCarveDateInputs.nth(2)).toHaveValue("17/04/2026");
await expect(mondayCarveDateInputs.nth(3)).toHaveValue("17/04/2026");
await mondayCancelButton.click();
await expect(mondayCancelButton).not.toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
}
});
test("resource timeline can delete a two-day weekday fragment without disturbing adjacent pieces", 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 row = page
.locator(
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
)
.first();
await expect(row).toBeVisible();
const baseSegment = row.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 leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
await expect(leftSplit).toBeVisible();
await expect(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible();
await openAllocationContextMenuAtOffset(page, leftSplit, dayWidth * 0.5);
await carveDateInputs.nth(2).fill("06/04/2026");
await carveDateInputs.nth(3).fill("07/04/2026");
await page.getByRole("button", { name: "Remove Selected Range" }).click();
await waitForScenarioAssignments(scenario.projectId, [
{ startDate: "2026-04-09", endDate: "2026-04-17" },
]);
await expect(
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
).toHaveCount(0);
await expect(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
).toHaveCount(0);
await expect(
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
).toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
}
});
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();
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
.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(projectRowSelector).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 switchToProjectView(page, projectRowSelector);
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 }) => {
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.locator(demandSelector)).toBeVisible();
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);
}
});
});