feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+57
View File
@@ -0,0 +1,57 @@
import { expect, test, type Page } from "@playwright/test";
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)/);
}
test.describe("Holiday Calendar Editor", () => {
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => {
const suffix = Date.now().toString();
const calendarName = `E2E City Calendar ${suffix}`;
const holidayName = `E2E Local Holiday ${suffix}`;
await signInAsAdmin(page);
await page.goto("/admin/vacations");
await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible();
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
await page.getByTestId("holiday-calendar-country-select").selectOption({ label: "Germany (DE)" });
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
await page.getByTestId("holiday-calendar-create-button").click();
await expect(page.getByTestId(/holiday-calendar-row-/).filter({ hasText: calendarName }).first()).toBeVisible();
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
await page.getByTestId("holiday-entry-date-input").fill("2026-08-08");
await page.getByTestId("holiday-entry-name-input").fill(holidayName);
await page.getByTestId("holiday-entry-source-input").fill("E2E");
await page.getByTestId("holiday-entry-create-button").click();
await expect(page.getByText(holidayName).first()).toBeVisible();
await page.getByTestId("holiday-preview-year-input").fill("2026");
await expect(page.getByTestId("holiday-preview-table")).toContainText(holidayName);
await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-08-08");
await page.getByTestId("holiday-entry-date-input").fill("2026-08-08");
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
await page.getByTestId("holiday-entry-create-button").click();
await expect(page.getByText("A holiday entry for this calendar and date already exists")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page.getByTestId(/holiday-entry-delete-/).first().click();
await expect(page.getByText(holidayName).first()).not.toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page.getByTestId("holiday-calendar-delete-button").click();
await expect(page.getByRole("heading", { name: calendarName })).not.toBeVisible();
});
});
+351
View File
@@ -0,0 +1,351 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const currentDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(currentDir, "../../..");
const webRoot = resolve(currentDir, "..");
const webEnvLocal = resolve(webRoot, ".env.local");
const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup");
const webDistDir = ".next-e2e";
const webDistDirPath = resolve(webRoot, webDistDir);
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
const composeProjectName = `capakraken-e2e-${process.pid}`;
const managedEnvKeys = [
"DATABASE_URL",
"REDIS_URL",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"AUTH_SECRET",
"E2E_TEST_MODE",
"NODE_ENV",
"PORT",
];
const e2eComposePrefix = "capakraken-e2e-";
function dockerComposeArgs(...args) {
return ["compose", "-p", composeProjectName, ...args];
}
function loadEnvFile(filePath) {
const env = {};
try {
const contents = readFileSync(filePath, "utf8");
for (const rawLine of contents.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separatorIndex = line.indexOf("=");
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const rawValue = line.slice(separatorIndex + 1).trim();
const quoted =
(rawValue.startsWith("\"") && rawValue.endsWith("\"")) ||
(rawValue.startsWith("'") && rawValue.endsWith("'"));
env[key] = quoted ? rawValue.slice(1, -1) : rawValue;
}
} catch {
// Keep local runs working even when no workspace .env is present.
}
return env;
}
function applyEnv(env) {
for (const [key, value] of Object.entries(env)) {
process.env[key] = value;
}
}
function writeManagedWebEnv(rootEnv) {
if (existsSync(webEnvBackup)) {
rmSync(webEnvBackup, { force: true });
}
if (existsSync(webEnvLocal)) {
renameSync(webEnvLocal, webEnvBackup);
}
const contents = managedEnvKeys
.map((key) => {
const value = rootEnv[key] ?? process.env[key];
return value ? `${key}=${value}` : null;
})
.filter(Boolean)
.join("\n");
writeFileSync(webEnvLocal, `${contents}\n`, "utf8");
}
function restoreWebEnv() {
if (existsSync(webEnvLocal)) {
rmSync(webEnvLocal, { force: true });
}
if (existsSync(webEnvBackup)) {
renameSync(webEnvBackup, webEnvLocal);
}
}
function run(command, args, cwd) {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
child.on("error", rejectPromise);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
return;
}
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`));
});
});
}
function runQuiet(command, args, cwd) {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: "ignore",
});
child.on("error", rejectPromise);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
return;
}
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`));
});
});
}
function runCapture(command, args, cwd) {
return new Promise((resolvePromise, rejectPromise) => {
let stdout = "";
let stderr = "";
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", rejectPromise);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise(stdout);
return;
}
rejectPromise(
new Error(
`${command} ${args.join(" ")} exited with code ${code ?? "null"}${stderr ? `: ${stderr.trim()}` : ""}`,
),
);
});
});
}
async function cleanupStaleE2eArtifacts() {
try {
const containerOutput = await runCapture("docker", ["ps", "-a", "--format", "{{.Names}}"], workspaceRoot);
const staleContainers = containerOutput
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((name) => name.startsWith(e2eComposePrefix));
if (staleContainers.length > 0) {
await runQuiet("docker", ["rm", "-f", ...staleContainers], workspaceRoot);
}
} catch {
// Best-effort cleanup only.
}
try {
const networkOutput = await runCapture("docker", ["network", "ls", "--format", "{{.Name}}"], workspaceRoot);
const staleNetworks = networkOutput
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((name) => name.startsWith(e2eComposePrefix));
if (staleNetworks.length > 0) {
await runQuiet("docker", ["network", "rm", ...staleNetworks], workspaceRoot);
}
} catch {
// Best-effort cleanup only.
}
}
async function ensureE2eDatabaseContainer() {
try {
await runQuiet("docker", dockerComposeArgs("rm", "-sf", "postgres-test"), workspaceRoot);
} catch {
// No previous test container to remove.
}
await run("docker", dockerComposeArgs("--profile", "test", "up", "-d", "--force-recreate", "postgres-test"), workspaceRoot);
const maxAttempts = 30;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await runQuiet(
"docker",
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
workspaceRoot,
);
return;
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
}
}
}
function parseDatabaseName(databaseUrl) {
const parsed = new URL(databaseUrl);
return parsed.pathname.replace(/^\/+/u, "");
}
async function canBindPort(port) {
return new Promise((resolvePromise) => {
const server = createServer();
server.once("error", () => {
resolvePromise(false);
});
server.once("listening", () => {
server.close(() => resolvePromise(true));
});
server.listen(port, "127.0.0.1");
});
}
async function selectAvailablePort(preferredPort) {
const candidates = [
preferredPort,
...Array.from({ length: 50 }, (_, index) => preferredPort + index + 1),
];
for (const candidate of candidates) {
if (await canBindPort(candidate)) {
return candidate;
}
}
throw new Error(`No free host port available for postgres-test near ${preferredPort}.`);
}
function replaceDatabasePort(databaseUrl, port) {
const parsed = new URL(databaseUrl);
parsed.port = String(port);
return parsed.toString();
}
let cleanedUpComposeProject = false;
async function cleanupComposeProject() {
if (cleanedUpComposeProject) {
return;
}
cleanedUpComposeProject = true;
try {
await runQuiet("docker", dockerComposeArgs("down", "--remove-orphans"), workspaceRoot);
} catch {
// Best-effort cleanup only.
}
}
const rootEnv = loadEnvFile(resolve(workspaceRoot, ".env"));
applyEnv(rootEnv);
let playwrightDatabaseUrl = process.env.PLAYWRIGHT_DATABASE_URL ?? process.env.DATABASE_URL_TEST;
if (!playwrightDatabaseUrl) {
throw new Error("PLAYWRIGHT_DATABASE_URL or DATABASE_URL_TEST must be configured for E2E runs.");
}
const requestedTestDbPort = Number(new URL(playwrightDatabaseUrl).port || "5434");
const selectedTestDbPort = await selectAvailablePort(requestedTestDbPort);
playwrightDatabaseUrl = replaceDatabasePort(playwrightDatabaseUrl, selectedTestDbPort);
const playwrightDatabaseName = parseDatabaseName(playwrightDatabaseUrl);
if (!/(^|_)(test|e2e|ci)$/u.test(playwrightDatabaseName)) {
throw new Error(
`Refusing to run E2E destructive setup against non-test database '${playwrightDatabaseName}'. Set PLAYWRIGHT_DATABASE_URL to an isolated test database.`,
);
}
process.env.DATABASE_URL = playwrightDatabaseUrl;
process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
process.env.PORT = e2ePort;
process.env.NEXTAUTH_URL = e2eBaseUrl;
process.env.AUTH_URL = e2eBaseUrl;
process.env.NEXT_DIST_DIR = webDistDir;
process.env.E2E_TEST_MODE = "true";
writeManagedWebEnv(rootEnv);
try {
await cleanupStaleE2eArtifacts();
await ensureE2eDatabaseContainer();
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot);
rmSync(webDistDirPath, { recursive: true, force: true });
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
cwd: webRoot,
env: process.env,
stdio: "inherit",
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
restoreWebEnv();
void cleanupComposeProject();
server.kill(signal);
});
}
server.on("exit", async (code) => {
restoreWebEnv();
await cleanupComposeProject();
process.exit(code ?? 0);
});
} catch (error) {
restoreWebEnv();
await cleanupComposeProject();
throw error;
}
+71 -11
View File
@@ -1,4 +1,12 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
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)/);
}
test.describe("Timeline", () => {
test.describe.configure({ mode: "serial" });
@@ -7,11 +15,7 @@ test.describe("Timeline", () => {
await page.addInitScript(() => {
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
});
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)/);
await signInAsAdmin(page);
await page.goto("/timeline");
});
@@ -87,8 +91,13 @@ test.describe("Timeline", () => {
.first();
const allocationPopoverField = page.getByText("Hours / day");
const resourceHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
await resourceHoverTarget.hover({ position: { x: 120, y: 20 } });
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 () => {
@@ -109,8 +118,19 @@ test.describe("Timeline", () => {
await expect(page.getByText(/projects/)).toBeVisible();
await page.waitForTimeout(500);
const projectHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
await projectHoverTarget.hover({ position: { x: 120, y: 20 } });
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 () => {
@@ -118,8 +138,48 @@ test.describe("Timeline", () => {
})
.toBe("rgba(3, 7, 18, 0.96)");
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
await projectAllocation.click({ button: "right" });
await expect(allocationPopoverField).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");
});
});
+69 -19
View File
@@ -1,13 +1,22 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
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 fillDisplayDate(page: Page, label: RegExp, value: string) {
const [year, month, day] = value.split("-");
await page.getByLabel(label).fill(`${day}/${month}/${year}`);
}
test.describe("Vacations", () => {
test.describe("My Vacations (self-service)", () => {
test.beforeEach(async ({ 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)/);
await signInAsAdmin(page);
await page.goto("/vacations/my");
});
@@ -23,25 +32,19 @@ test.describe("Vacations", () => {
).toBeVisible({ timeout: 10000 });
});
test("request vacation modal opens", async ({ page }) => {
test("request vacation is blocked without linked resource", async ({ page }) => {
await page.waitForLoadState("networkidle");
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
await reqBtn.click();
// Modal should show vacation form
await expect(reqBtn).toBeDisabled();
await expect(
page.locator("text=Request Vacation").or(page.locator("text=Vacation Type")),
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
).toBeVisible({ timeout: 5000 });
await page.keyboard.press("Escape");
});
});
test.describe("Vacation Management", () => {
test.beforeEach(async ({ 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)/);
await signInAsAdmin(page);
await page.goto("/vacations");
});
@@ -62,12 +65,59 @@ test.describe("Vacations", () => {
).toBeVisible({ timeout: 10000 });
});
test("filter chips are visible on list tab", async ({ page }) => {
test("filter controls are visible on list tab", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Status filter options should be visible
const filters = page.getByRole("combobox");
await expect(filters).toHaveCount(3);
await expect(filters.nth(0)).toHaveValue("ALL");
await expect(filters.nth(1)).toHaveValue("ALL");
await expect(filters.nth(2)).toHaveValue("");
});
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /request vacation/i }).click();
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
await page.getByLabel(/^type/i).selectOption("ANNUAL");
await fillDisplayDate(page, /start date/i, "2026-01-06");
await fillDisplayDate(page, /end date/i, "2026-01-06");
await expect(page.getByTestId("vacation-preview-card")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
});
});
test.describe("Admin Holiday Calendar", () => {
test.beforeEach(async ({ page }) => {
await signInAsAdmin(page);
await page.goto("/admin/vacations");
});
test("seeded holiday calendars can be selected and previewed", async ({ page }) => {
await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible({ timeout: 10000 });
const germanyCalendarRow = page
.getByTestId(/holiday-calendar-row-/)
.filter({ hasText: "Referenzfeiertage Deutschland 2026-2027" })
.first();
await expect(germanyCalendarRow).toBeVisible({ timeout: 10000 });
await germanyCalendarRow.click();
await expect(
page.locator("button", { hasText: /All|Pending|Approved/i }).first(),
page.getByRole("heading", { name: "Referenzfeiertage Deutschland 2026-2027" }),
).toBeVisible({ timeout: 10000 });
await page.getByTestId("holiday-preview-year-input").fill("2026");
await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-01-01");
await expect(page.getByTestId("holiday-preview-table")).toContainText("Neujahr");
});
});
});