diff --git a/.claude/helpers/session.js b/.claude/helpers/session.js index 11e2ec0..041e485 100644 --- a/.claude/helpers/session.js +++ b/.claude/helpers/session.js @@ -7,7 +7,8 @@ const fs = require('fs'); const path = require('path'); -const SESSION_DIR = path.join(process.cwd(), '.claude-flow', 'sessions'); +const WORKSPACE_ROOT = fs.realpathSync(process.cwd()); +const SESSION_DIR = path.join(WORKSPACE_ROOT, '.claude-flow', 'sessions'); const SESSION_FILE = path.join(SESSION_DIR, 'current.json'); const commands = { @@ -16,7 +17,7 @@ const commands = { const session = { id: sessionId, startedAt: new Date().toISOString(), - cwd: process.cwd(), + cwd: WORKSPACE_ROOT, context: {}, metrics: { edits: 0, diff --git a/.claude/helpers/statusline.js b/.claude/helpers/statusline.js index 96c9342..b24dd4f 100644 --- a/.claude/helpers/statusline.js +++ b/.claude/helpers/statusline.js @@ -10,6 +10,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); +const WORKSPACE_ROOT = fs.realpathSync(process.cwd()); + // Configuration const CONFIG = { enabled: true, @@ -62,9 +64,9 @@ function getUserInfo() { // Get learning stats from memory database function getLearningStats() { const memoryPaths = [ - path.join(process.cwd(), '.swarm', 'memory.db'), - path.join(process.cwd(), '.claude', 'memory.db'), - path.join(process.cwd(), 'data', 'memory.db'), + path.join(WORKSPACE_ROOT, '.swarm', 'memory.db'), + path.join(WORKSPACE_ROOT, '.claude', 'memory.db'), + path.join(WORKSPACE_ROOT, 'data', 'memory.db'), ]; let patterns = 0; @@ -90,7 +92,7 @@ function getLearningStats() { } // Also check for session files - const sessionsPath = path.join(process.cwd(), '.claude', 'sessions'); + const sessionsPath = path.join(WORKSPACE_ROOT, '.claude', 'sessions'); if (fs.existsSync(sessionsPath)) { try { const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.json')); @@ -132,7 +134,7 @@ function getV3Progress() { // Get security status based on actual scans function getSecurityStatus() { // Check for security scan results in memory - const scanResultsPath = path.join(process.cwd(), '.claude', 'security-scans'); + const scanResultsPath = path.join(WORKSPACE_ROOT, '.claude', 'security-scans'); let cvesFixed = 0; const totalCves = 3; @@ -147,7 +149,7 @@ function getSecurityStatus() { } // Also check .swarm/security for audit results - const auditPath = path.join(process.cwd(), '.swarm', 'security'); + const auditPath = path.join(WORKSPACE_ROOT, '.swarm', 'security'); if (fs.existsSync(auditPath)) { try { const audits = fs.readdirSync(auditPath).filter(f => f.includes('audit')); diff --git a/.claude/helpers/swarm-monitor.sh b/.claude/helpers/swarm-monitor.sh index bc4fef4..1e21628 100755 --- a/.claude/helpers/swarm-monitor.sh +++ b/.claude/helpers/swarm-monitor.sh @@ -2,8 +2,8 @@ # Claude Flow V3 - Real-time Swarm Activity Monitor # Continuously monitors and updates metrics based on running processes -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)" METRICS_DIR="$PROJECT_ROOT/.claude-flow/metrics" UPDATE_SCRIPT="$SCRIPT_DIR/update-v3-progress.sh" @@ -208,4 +208,4 @@ case "${1:-check}" in echo "Use '$0 help' for usage information" exit 1 ;; -esac \ No newline at end of file +esac diff --git a/.gitignore b/.gitignore index db6e0f8..6ee498d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ # Build outputs .next/ .next-e2e/ +**/.next.* **/.next.root-owned.* dist/ build/ @@ -70,3 +71,4 @@ packages/db/prisma/migrations/* # Never commit workbook source files *.xls *.xlsx +.gstack/ diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts index 5eea395..8317fa1 100644 --- a/apps/web/e2e/resources.spec.ts +++ b/apps/web/e2e/resources.spec.ts @@ -6,19 +6,52 @@ test.describe("Resources", () => { await page.fill('input[type="email"]', "manager@capakraken.dev"); await page.fill('input[type="password"]', "manager123"); await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/resources"); await expect(page).toHaveURL(/\/resources/); }); test("shows resources list", async ({ page }) => { - await expect(page.locator("h1")).toContainText("Resources"); + await expect(page.getByRole("heading", { name: "Resources" })).toBeVisible(); await expect(page.locator("table")).toBeVisible(); }); test("can search resources", async ({ page }) => { + const rows = page.locator("tbody tr"); + await expect(rows.first()).toBeVisible(); + + const firstRowText = (await rows.first().textContent()) ?? ""; + const searchTerm = firstRowText + .split(/\s+/) + .map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim()) + .find((token) => token.length >= 3) ?? "EMP"; + const searchInput = page.locator('input[type="search"]'); - await searchInput.fill("EMP-001"); + await searchInput.fill(searchTerm); await page.waitForTimeout(500); - // Should show filtered results - await expect(page.locator("tbody tr")).toHaveCount(1); + await expect(searchInput).toHaveValue(searchTerm); + await expect(page.getByText(`Search: "${searchTerm}"`)).toBeVisible(); + }); + + test("shows a not-found state for a missing resource detail page", async ({ page }) => { + await page.goto("/resources/does-not-exist"); + + await expect(page.getByText("Resource not found.")).toBeVisible(); + await expect(page.getByRole("link", { name: "Back to resources" })).toBeVisible(); + }); + + test("shows a generic load error when the resource detail query fails", async ({ page }) => { + await page.route("**/api/trpc/resource.getById**", async (route) => { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: { message: "boom" } }), + }); + }); + + await page.goto("/resources/test-resource-id"); + + await expect(page.getByText("This resource could not be loaded right now.")).toBeVisible(); + await expect(page.getByRole("link", { name: "Back to resources" })).toBeVisible(); }); }); diff --git a/apps/web/e2e/test-server.mjs b/apps/web/e2e/test-server.mjs index 887c01d..76a225e 100644 --- a/apps/web/e2e/test-server.mjs +++ b/apps/web/e2e/test-server.mjs @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { dirname, resolve } from "node:path"; @@ -7,12 +8,16 @@ import { fileURLToPath } from "node:url"; const currentDir = dirname(fileURLToPath(import.meta.url)); const workspaceRoot = resolve(currentDir, "../../.."); const webRoot = resolve(currentDir, ".."); +const runtimeEnvPath = resolve(currentDir, ".playwright-runtime.json"); const webEnvLocal = resolve(webRoot, ".env.local"); const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup"); const webDistDir = ".next-e2e"; const webDistDirPath = resolve(webRoot, webDistDir); +const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs"; const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110"; const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`; +const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`; +const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true"; const composeProjectName = `capakraken-e2e-${process.pid}`; const managedEnvKeys = [ "DATABASE_URL", @@ -68,12 +73,24 @@ function applyEnv(env) { } function writeManagedWebEnv(rootEnv) { - if (existsSync(webEnvBackup)) { + if (!manageWebEnvFile) { + restoreWebEnv(); + return; + } + + if (existsSync(webEnvBackup) && isManagedEnvFile(webEnvBackup)) { rmSync(webEnvBackup, { force: true }); } if (existsSync(webEnvLocal)) { - renameSync(webEnvLocal, webEnvBackup); + if (isManagedEnvFile(webEnvLocal)) { + rmSync(webEnvLocal, { force: true }); + } else { + if (existsSync(webEnvBackup)) { + rmSync(webEnvBackup, { force: true }); + } + renameSync(webEnvLocal, webEnvBackup); + } } const contents = managedEnvKeys @@ -84,16 +101,41 @@ function writeManagedWebEnv(rootEnv) { .filter(Boolean) .join("\n"); - writeFileSync(webEnvLocal, `${contents}\n`, "utf8"); + writeFileSync(webEnvLocal, `${managedEnvBanner}\n${contents}\n`, "utf8"); } function restoreWebEnv() { - if (existsSync(webEnvLocal)) { + if (existsSync(webEnvLocal) && isManagedEnvFile(webEnvLocal)) { rmSync(webEnvLocal, { force: true }); } if (existsSync(webEnvBackup)) { - renameSync(webEnvBackup, webEnvLocal); + if (isManagedEnvFile(webEnvBackup)) { + rmSync(webEnvBackup, { force: true }); + } else { + renameSync(webEnvBackup, webEnvLocal); + } + } +} + +let restoredManagedEnv = false; + +function restoreWebEnvOnce() { + if (restoredManagedEnv) { + return; + } + + restoredManagedEnv = true; + restoreWebEnv(); + rmSync(runtimeEnvPath, { force: true }); +} + +function isManagedEnvFile(filePath) { + try { + const contents = readFileSync(filePath, "utf8"); + return contents.includes(managedEnvBanner) || contents.includes("E2E_TEST_MODE=true"); + } catch { + return false; } } @@ -307,15 +349,33 @@ if (!/(^|_)(test|e2e|ci)$/u.test(playwrightDatabaseName)) { process.env.DATABASE_URL = playwrightDatabaseUrl; process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl; process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort); +process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName; 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.NEXTAUTH_SECRET = e2eAuthSecret; +process.env.AUTH_SECRET = e2eAuthSecret; process.env.NEXT_DIST_DIR = webDistDir; process.env.E2E_TEST_MODE = "true"; +writeFileSync( + runtimeEnvPath, + JSON.stringify( + { + DATABASE_URL: process.env.DATABASE_URL, + PLAYWRIGHT_DATABASE_URL: process.env.PLAYWRIGHT_DATABASE_URL, + POSTGRES_TEST_PORT: process.env.POSTGRES_TEST_PORT, + BASE_URL: e2eBaseUrl, + }, + null, + 2, + ), + "utf8", +); writeManagedWebEnv(rootEnv); +process.on("exit", restoreWebEnvOnce); try { await cleanupStaleE2eArtifacts(); @@ -333,19 +393,19 @@ try { for (const signal of ["SIGINT", "SIGTERM"]) { process.on(signal, () => { - restoreWebEnv(); + restoreWebEnvOnce(); void cleanupComposeProject(); server.kill(signal); }); } server.on("exit", async (code) => { - restoreWebEnv(); + restoreWebEnvOnce(); await cleanupComposeProject(); process.exit(code ?? 0); }); } catch (error) { - restoreWebEnv(); + restoreWebEnvOnce(); await cleanupComposeProject(); throw error; } diff --git a/apps/web/e2e/timeline.spec.ts b/apps/web/e2e/timeline.spec.ts index 3341b5e..7940056 100644 --- a/apps/web/e2e/timeline.spec.ts +++ b/apps/web/e2e/timeline.spec.ts @@ -1,4 +1,383 @@ 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(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; +}; + +function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario { + return runDbJson(` + const availability = { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }; + + const resource = await prisma.resource.create({ + data: { + eid: ${JSON.stringify(`e2e.timeline.${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 cleanupTimelineSegmentScenario(projectId: string, resourceId: string) { + runDbJson(` + 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 listScenarioAssignments(projectId: string) { + return runDbJson>(` + 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 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, 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, + xOffset: number, +) { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + if (!box) { + throw new Error("Expected allocation segment to be visible before context click"); + } + + await page.mouse.click(box.x + xOffset, 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, + 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) { + 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 listRenderedAllocationSegments( + row: ReturnType, + 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"); @@ -8,6 +387,239 @@ async function signInAsAdmin(page: Page) { 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); +} + +async function findVisibleAllocationIdForResize(page: Page, selector: string) { + return page.locator(selector).evaluateAll((elements) => { + const candidates: Array<{ id: string; score: number }> = []; + let fallbackId: string | null = null; + + for (const element of elements) { + if (!(element instanceof HTMLElement)) continue; + const id = element.dataset.allocationId; + if (!id) continue; + + const rect = element.getBoundingClientRect(); + const isVisible = + rect.width >= 24 && + rect.height >= 10 && + rect.right > 48 && + rect.left < window.innerWidth - 48 && + rect.bottom > 0 && + rect.top < window.innerHeight; + + if (!isVisible) { + continue; + } + + fallbackId ??= id; + + const isPreferred = + rect.width >= 36 && + rect.width <= 220 && + rect.left >= 48 && + rect.right <= window.innerWidth - 48; + + 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({ id, score: widthPenalty + centerPenalty }); + } + + candidates.sort((a, b) => a.score - b.score); + return candidates[0]?.id ?? fallbackId; + }); +} + +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), + }; +} + test.describe("Timeline", () => { test.describe.configure({ mode: "serial" }); @@ -123,9 +735,7 @@ test.describe("Timeline", () => { .toBe("rgba(3, 7, 18, 0.96)"); const resourceAllocation = page - .locator( - "div.absolute.rounded-md.flex.items-stretch.overflow-hidden.transition-all.duration-75", - ) + .locator("[data-timeline-entry-type='allocation'][data-allocation-id]") .first(); await resourceAllocation.click({ button: "right" }); await expect(allocationPopoverField).toBeVisible(); @@ -159,6 +769,41 @@ test.describe("Timeline", () => { 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.getByText("Loading...")).not.toBeVisible({ timeout: 2000 }); + await expect(page.getByText("Hours / day")).toBeVisible(); + await expect(page.getByRole("button", { name: "Open Project Panel →" })).toBeVisible(); + }); + + 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(); + + 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, }) => { @@ -199,4 +844,630 @@ test.describe("Timeline", () => { 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 allocationId = await findVisibleAllocationIdForResize( + page, + "[data-timeline-entry-type='allocation'][data-allocation-id]", + ); + expect(allocationId).toBeTruthy(); + + const allocation = page.locator( + `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, + ); + await allocation.click({ button: "right" }); + + 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 allocationId = await findVisibleAllocationIdForResize( + page, + "[data-timeline-entry-type='allocation'][data-allocation-id]", + ); + expect(allocationId).toBeTruthy(); + + const allocation = page.locator( + `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, + ); + await allocation.click({ button: "right" }); + + 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("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 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 allocationId = await findVisibleAllocationIdForResize( + page, + "[data-timeline-entry-type='allocation'][data-allocation-id]", + ); + expect(allocationId).toBeTruthy(); + + const result = await measureAllocationDragGap( + page, + `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, + ); + + 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 allocationId = await findVisibleAllocationIdForResize( + page, + "[data-timeline-entry-type='allocation'][data-allocation-id]", + ); + expect(allocationId).toBeTruthy(); + + const result = await measureAllocationResizeGap( + page, + `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, + ); + + expect(result.widthGain).toBeGreaterThan(64); + expect(result.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 allocationId = await findVisibleAllocationIdForResize( + page, + "[data-timeline-entry-type='allocation'][data-allocation-id]", + ); + expect(allocationId).toBeTruthy(); + + const result = await measureAllocationResizeStartGap( + page, + `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, + ); + + expect(result.widthGain).toBeGreaterThan(48); + expect(result.leftEdgeGain).toBeGreaterThan(36); + }); + + 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("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 demandId = await findVisibleTimelineEntryId( + page, + "[data-timeline-entry-type='demand'][data-allocation-id]", + 72, + ); + expect(demandId).toBeTruthy(); + + const demandBar = page.locator( + `[data-timeline-entry-type='demand'][data-allocation-id='${demandId}']`, + ); + await demandBar.hover(); + + 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"); + }); }); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 830fb59..2781e99 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/api/cron/chargeability-alerts/route.ts b/apps/web/src/app/api/cron/chargeability-alerts/route.ts index b25f80f..2e8000e 100644 --- a/apps/web/src/app/api/cron/chargeability-alerts/route.ts +++ b/apps/web/src/app/api/cron/chargeability-alerts/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { checkChargeabilityAlerts } from "@capakraken/api"; +import { logger } from "@capakraken/api/lib/logger"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -35,7 +36,7 @@ export async function GET(request: Request) { checkedAt: new Date().toISOString(), }); } catch (error) { - console.error("[cron/chargeability-alerts] Error:", error); + logger.error({ error, route: "/api/cron/chargeability-alerts" }, "Chargeability alert cron failed"); return NextResponse.json( { ok: false, error: "Internal error" }, { status: 500 }, diff --git a/apps/web/src/app/api/cron/estimate-reminders/route.ts b/apps/web/src/app/api/cron/estimate-reminders/route.ts index 32bcce1..5a1ce2a 100644 --- a/apps/web/src/app/api/cron/estimate-reminders/route.ts +++ b/apps/web/src/app/api/cron/estimate-reminders/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { checkPendingEstimateReminders } from "@capakraken/api"; +import { logger } from "@capakraken/api/lib/logger"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -37,7 +38,7 @@ export async function GET(request: Request) { checkedAt: new Date().toISOString(), }); } catch (error) { - console.error("[cron/estimate-reminders] Error:", error); + logger.error({ error, route: "/api/cron/estimate-reminders" }, "Estimate reminder cron failed"); return NextResponse.json( { ok: false, error: "Internal error" }, { status: 500 }, diff --git a/apps/web/src/app/api/cron/health-check/route.ts b/apps/web/src/app/api/cron/health-check/route.ts index aafa177..e7532b7 100644 --- a/apps/web/src/app/api/cron/health-check/route.ts +++ b/apps/web/src/app/api/cron/health-check/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { createNotificationsForUsers } from "@capakraken/api"; +import { logger } from "@capakraken/api/lib/logger"; import { createConnection } from "net"; export const dynamic = "force-dynamic"; @@ -123,7 +124,7 @@ export async function GET(request: Request) { { status: allHealthy ? 200 : 503 }, ); } catch (error) { - console.error("[cron/health-check] Error:", error); + logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed"); return NextResponse.json( { ok: false, error: "Internal error" }, { status: 500 }, diff --git a/apps/web/src/app/api/cron/public-holidays/route.ts b/apps/web/src/app/api/cron/public-holidays/route.ts index 5f50483..0a91513 100644 --- a/apps/web/src/app/api/cron/public-holidays/route.ts +++ b/apps/web/src/app/api/cron/public-holidays/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { autoImportPublicHolidays } from "@capakraken/api"; +import { logger } from "@capakraken/api/lib/logger"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -49,7 +50,7 @@ export async function GET(request: Request) { skippedExisting: result.skippedExisting, }); } catch (error) { - console.error("[cron/public-holidays] Error:", error); + logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed"); return NextResponse.json( { ok: false, error: "Internal error" }, { status: 500 }, diff --git a/apps/web/src/app/api/cron/security-audit/route.ts b/apps/web/src/app/api/cron/security-audit/route.ts index 1a38f99..353693d 100644 --- a/apps/web/src/app/api/cron/security-audit/route.ts +++ b/apps/web/src/app/api/cron/security-audit/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { createNotificationsForUsers } from "@capakraken/api"; +import { logger } from "@capakraken/api/lib/logger"; import { readFileSync } from "fs"; import { join } from "path"; @@ -87,7 +88,7 @@ function scanPackageJson(): Finding[] { } } } catch (error) { - console.error("[security-audit] Error scanning package.json:", error); + logger.error({ error, route: "/api/cron/security-audit" }, "Failed to scan package manifests for security audit"); } return findings; @@ -149,7 +150,7 @@ export async function GET(request: Request) { scannedAt: new Date().toISOString(), }); } catch (error) { - console.error("[cron/security-audit] Error:", error); + logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed"); return NextResponse.json( { ok: false, error: "Internal error" }, { status: 500 }, diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 3d32d61..e7e473c 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -45,8 +45,19 @@ const handler = async (req: NextRequest) => { }; if (process.env["NODE_ENV"] === "development") { - options.onError = ({ path, error }: { path?: string; error: { message: string } }) => { - console.error(`❌ tRPC failed on ${path ?? ""}: ${error.message}`); + options.onError = ({ + path, + error, + }: { + path?: string; + error: { message: string; code?: string }; + }) => { + const label = `tRPC ${path ?? ""}`; + if (error.code === "NOT_FOUND") { + console.warn(`⚠️ ${label}: ${error.message}`); + return; + } + console.error(`❌ ${label}: ${error.message}`); }; } diff --git a/apps/web/src/components/comments/CommentThread.tsx b/apps/web/src/components/comments/CommentThread.tsx index 71037a1..e47e7a6 100644 --- a/apps/web/src/components/comments/CommentThread.tsx +++ b/apps/web/src/components/comments/CommentThread.tsx @@ -1,5 +1,6 @@ "use client"; +import type { CommentEntityType } from "@capakraken/shared"; import { useState } from "react"; import { clsx } from "clsx"; import { trpc } from "~/lib/trpc/client.js"; @@ -7,6 +8,11 @@ import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { CommentInput } from "./CommentInput.js"; import { sanitizeHtml } from "~/lib/sanitize.js"; +interface CommentTarget { + entityType: CommentEntityType; + entityId: string; +} + interface CommentAuthor { id: string; name: string | null; @@ -32,7 +38,7 @@ interface CommentItem { } interface CommentThreadProps { - entityId: string; + commentTarget: CommentTarget; } function formatRelativeTime(date: Date | string): string { @@ -110,18 +116,16 @@ function CommentBody({ body }: { body: string }) { function SingleComment({ comment, - entityId, + commentTarget, isReply = false, }: { comment: CommentItem | CommentReply; - entityId: string; + commentTarget: CommentTarget; isReply?: boolean; }) { const [showReplyInput, setShowReplyInput] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const utils = trpc.useUtils(); - const commentTarget = { entityType: "estimate" as const, entityId }; - const createMutation = trpc.comment.create.useMutation({ onSuccess: () => { setShowReplyInput(false); @@ -212,17 +216,17 @@ function SingleComment({ {/* Inline reply input */} {showReplyInput && (
- { - createMutation.mutate({ - entityType: commentTarget.entityType, - entityId, - parentId: comment.id, - body: replyBody, - }); + { + createMutation.mutate({ + entityType: commentTarget.entityType, + entityId: commentTarget.entityId, + parentId: comment.id, + body: replyBody, + }); }} onCancel={() => setShowReplyInput(false)} isSubmitting={createMutation.isPending} @@ -255,7 +259,7 @@ function SingleComment({ ))} @@ -265,9 +269,8 @@ function SingleComment({ ); } -export function CommentThread({ entityId }: CommentThreadProps) { +export function CommentThread({ commentTarget }: CommentThreadProps) { const utils = trpc.useUtils(); - const commentTarget = { entityType: "estimate" as const, entityId }; const commentsQuery = trpc.comment.list.useQuery( commentTarget, @@ -308,7 +311,7 @@ export function CommentThread({ entityId }: CommentThreadProps) { ))}
@@ -318,11 +321,11 @@ export function CommentThread({ entityId }: CommentThreadProps) {
{ createMutation.mutate({ entityType: commentTarget.entityType, - entityId, + entityId: commentTarget.entityId, body, }); }} diff --git a/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx b/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx index e987d3a..f141b3e 100644 --- a/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx @@ -42,6 +42,19 @@ type BudgetForecastRow = { pctUsed: number; activeAssignmentCount?: number; calendarLocations?: BudgetForecastLocation[]; + derivation?: { + periodStart: string; + periodEnd: string; + calendarContextCount: number; + holidayAwareAssignmentCount: number; + fallbackAssignmentCount: number; + baseBurnRateCents: number; + adjustedBurnRateCents: number; + publicHolidayDayEquivalent: number; + publicHolidayCostDeductionCents: number; + absenceDayEquivalent: number; + absenceCostDeductionCents: number; + } | null; }; function formatCurrency(cents: number | undefined): string { @@ -49,6 +62,11 @@ function formatCurrency(cents: number | undefined): string { return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`; } +function formatDayEquivalent(value: number | undefined): string { + if (value === undefined) return "—"; + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + function formatLocation(location: BudgetForecastLocation): string { const parts = [ location.countryCode ?? location.countryName ?? null, @@ -65,7 +83,7 @@ function SummaryCard({ }: { label: string; value: string; - helper: string; + helper?: string; }) { return (
@@ -73,7 +91,9 @@ function SummaryCard({ {label}
{value}
-
{helper}
+ {helper ? ( +
{helper}
+ ) : null}
); } @@ -113,6 +133,9 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents); acc.burnRate += row.burnRate; acc.activeAssignmentCount += row.activeAssignmentCount ?? 0; + acc.baseBurnRateCents += row.derivation?.baseBurnRateCents ?? row.burnRate; + acc.publicHolidayCostDeductionCents += row.derivation?.publicHolidayCostDeductionCents ?? 0; + acc.absenceCostDeductionCents += row.derivation?.absenceCostDeductionCents ?? 0; return acc; }, { budgetCents: 0, @@ -120,6 +143,9 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { remainingCents: 0, burnRate: 0, activeAssignmentCount: 0, + baseBurnRateCents: 0, + publicHolidayCostDeductionCents: 0, + absenceCostDeductionCents: 0, }), [rows]); if (isLoading && !data) { @@ -154,22 +180,26 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`} + {...(showDetails + ? { helper: `${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted` } + : {})} />
@@ -200,15 +230,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
{row.clientName ?? "No client"} - {!showDetails && row.calendarLocations && row.calendarLocations.length > 0 - ? ` · ${formatLocation(row.calendarLocations[0]!)}` - : ""}
{showDetails ? (
{row.activeAssignmentCount ?? 0} active assignments
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
+ {row.derivation ? ( + <> +
{row.derivation.calendarContextCount} calendar bases
+
+ {row.derivation.holidayAwareAssignmentCount} holiday-aware + {row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""} +
+ + ) : null}
{row.calendarLocations && row.calendarLocations.length > 0 ? ( @@ -254,7 +290,22 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
{showDetails ? (
-
{row.activeAssignmentCount ?? 0} active assignments
+ {row.derivation ? ( + <> +
+ Base {formatCurrency(row.derivation.baseBurnRateCents)} {"->"} Adj {formatCurrency(row.derivation.adjustedBurnRateCents)} +
+
+ Hol -{formatCurrency(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d) · Abs -{formatCurrency(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d) +
+
+ {row.derivation.holidayAwareAssignmentCount} holiday-aware assignment{row.derivation.holidayAwareAssignmentCount === 1 ? "" : "s"} + {row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""} +
+ + ) : ( +
{row.activeAssignmentCount ?? 0} active assignments
+ )} {(row.calendarLocations ?? []).slice(0, 3).map((location) => (
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)} diff --git a/apps/web/src/components/dashboard/widgets/DemandWidget.tsx b/apps/web/src/components/dashboard/widgets/DemandWidget.tsx index 206a383..b4cb9fc 100644 --- a/apps/web/src/components/dashboard/widgets/DemandWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/DemandWidget.tsx @@ -53,6 +53,17 @@ function formatDemandSource(source: DemandDerivation["demandSource"] | undefined return "No demand basis"; } +function renderCalendarBasis(derivation: DemandDerivation): string { + if (derivation.calendarLocations.length === 0) { + return "No location-based booking basis"; + } + + return derivation.calendarLocations + .slice(0, 2) + .map((location) => `${formatLocation(location)} (${formatHours(location.allocatedHours)})`) + .join(" · "); +} + export function DemandWidget({ config, onConfigChange }: WidgetProps) { const showDetails = config.showDetails === true; const groupBy = (config.groupBy as GroupBy) || "project"; @@ -198,21 +209,15 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) { row.name )}
- {showDetails && groupBy === "project" && row.derivation ? ( + {showDetails && row.derivation ? (
{row.derivation.periodStart} to {row.derivation.periodEnd}
-
- {row.derivation.calendarLocations.length > 0 - ? row.derivation.calendarLocations - .slice(0, 2) - .map((location) => - `${formatLocation(location)} (${formatHours(location.allocatedHours)})`, - ) - .join(" · ") - : "No location-based booking basis"} -
+
{renderCalendarBasis(row.derivation)}
+ {groupBy !== "project" ? ( +
{formatHours(row.derivation.periodWorkingHoursBase)} per 1.0 FTE base
+ ) : null} {row.derivation.calendarLocations.length > 2 ? (
+ {row.derivation.calendarLocations.length - 2} more calendar contexts
) : null} @@ -221,7 +226,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
{row.allocatedHours}h
- {showDetails && groupBy === "project" && row.derivation ? ( + {showDetails && row.derivation ? (
{row.derivation.calendarLocations.length} calendar basis{row.derivation.calendarLocations.length === 1 ? "" : "es"}
{row.resourceCount} resource{row.resourceCount === 1 ? "" : "s"} in scope
@@ -262,7 +267,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) { )}
{row.resourceCount}
- {showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? ( + {showDetails && row.derivation?.calendarLocations.length ? (
{row.derivation.calendarLocations.reduce((sum, location) => sum + location.resourceCount, 0)} resource entries across locations
diff --git a/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx b/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx index c2bebee..0db0c14 100644 --- a/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx +++ b/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx @@ -7,10 +7,23 @@ type PeakTimesChartRow = { label: string; bookedHours: number; capacityHours: number; + baseAvailableHours: number; + holidayHoursDeduction: number; + absenceDayEquivalent: number; + absenceHoursDeduction: number; utilizationPct: number; remainingHours: number; overbookedHours: number; isCurrentPeriod: boolean; + calendarContextCount: number; + calendarLocations: Array<{ + countryCode: string | null; + countryName: string | null; + federalState: string | null; + metroCityName: string | null; + resourceCount: number; + effectiveAvailableHours: number; + }>; }; interface PeakTimesChartProps { @@ -26,6 +39,16 @@ function formatHours(value: number): string { }).format(value); } +function formatDayEquivalent(value: number): string { + return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1); +} + +function formatLocation(input: PeakTimesChartRow["calendarLocations"][number]): string { + const parts = [input.countryCode ?? input.countryName, input.federalState, input.metroCityName] + .filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" / ") : "No calendar context"; +} + function utilizationBarTone(utilizationPct: number): string { if (utilizationPct > 100) return "bg-red-500"; if (utilizationPct > 75) return "bg-emerald-500"; @@ -132,7 +155,19 @@ export default function PeakTimesChart({ key={row.period} type="button" className="group flex h-full min-w-0 flex-col items-center rounded-2xl px-1 text-left transition-colors" - title={`${row.label}: ${row.utilizationPct}% utilization, ${formatHours(row.bookedHours)}h booked, ${formatHours(row.capacityHours)}h capacity, ${formatHours(row.remainingHours)}h free, ${formatHours(row.overbookedHours)}h overbooked`} + title={[ + `${row.label}: ${row.utilizationPct}% utilization`, + `${formatHours(row.bookedHours)}h booked`, + `${formatHours(row.capacityHours)}h effective capacity`, + `${formatHours(row.baseAvailableHours)}h base`, + `${formatHours(row.holidayHoursDeduction)}h holidays`, + `${formatHours(row.absenceHoursDeduction)}h absences (${formatDayEquivalent(row.absenceDayEquivalent)}d)`, + `${row.calendarContextCount} calendar base${row.calendarContextCount === 1 ? "" : "s"}`, + ...row.calendarLocations.slice(0, 3).map((location) => + `${formatLocation(location)}: ${location.resourceCount}x, ${formatHours(location.effectiveAvailableHours)}h capacity`), + `${formatHours(row.remainingHours)}h free`, + `${formatHours(row.overbookedHours)}h overbooked`, + ].join(", ")} onMouseEnter={() => setHoveredPeriod(row.period)} onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))} onClick={() => onSelectedPeriodChange?.(row.period)} diff --git a/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx b/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx index 60bda47..8da0705 100644 --- a/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx @@ -10,6 +10,51 @@ import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/Widge import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js"; import { formatMoney } from "~/lib/format.js"; +type ProjectHealthRow = { + id: string; + projectName: string; + shortCode: string; + status: string; + clientId: string | null; + clientName: string | null; + budgetHealth: number; + staffingHealth: number; + timelineHealth: number; + compositeScore: number; + budgetCents?: number | null; + spentCents?: number; + remainingBudgetCents?: number | null; + budgetUtilizationPercent?: number | null; + demandHeadcountTotal?: number; + demandHeadcountFilled?: number; + demandHeadcountOpen?: number; + demandRequirementCount?: number; + plannedEndDate?: string | Date | null; + daysUntilEndDate?: number | null; + timelineStatus?: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED" | null; + calendarLocations?: Array<{ + countryCode?: string | null; + countryName?: string | null; + federalState?: string | null; + metroCityName?: string | null; + assignmentCount: number; + spentCents: number; + }>; + derivation?: { + periodStart: string; + periodEnd: string; + calendarContextCount: number; + holidayAwareAssignmentCount: number; + fallbackAssignmentCount: number; + baseSpentCents: number; + adjustedSpentCents: number; + publicHolidayDayEquivalent: number; + publicHolidayCostDeductionCents: number; + absenceDayEquivalent: number; + absenceCostDeductionCents: number; + } | null; +}; + function healthDot(value: number): string { if (value >= 70) return "bg-green-500"; if (value >= 40) return "bg-amber-400"; @@ -69,6 +114,11 @@ function formatLocation(location: { return parts.length > 0 ? parts.join(" / ") : "No calendar context"; } +function formatDayEquivalent(value?: number | null): string { + if (value == null) return "—"; + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) { const showDetails = config.showDetails === true; const { clients } = useWidgetFilterOptions({ clients: true }); @@ -90,7 +140,7 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) { const clientId = (config.clientId as string) ?? ""; const rows = useMemo(() => { - const all = data ?? []; + const all = (data ?? []) as ProjectHealthRow[]; return all.filter((r) => { if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false; if (clientId && r.clientId !== clientId) return false; @@ -174,6 +224,22 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
+ {row.derivation ? ( + <> +
+ Spend basis: {row.derivation.calendarContextCount} calendar bases · {row.derivation.holidayAwareAssignmentCount} holiday-aware + {row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""} +
+
+ Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective {formatMoney(row.derivation.adjustedSpentCents)} +
+
+ Holidays -{formatMoney(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d) + {" · "} + Absence -{formatMoney(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d) +
+ + ) : null} {(row.calendarLocations ?? []).length > 0 ? (
Calendar basis: {(row.calendarLocations ?? []) diff --git a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx index 7f4672d..aea9a2d 100644 --- a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx +++ b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import dynamic from "next/dynamic"; import { EstimateExportFormat } from "@capakraken/shared"; import { clsx } from "clsx"; +import { useSession } from "next-auth/react"; import type { EstimateWorkspaceView, WorkspaceTab, @@ -113,17 +114,19 @@ function ActionNotice({ } export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) { + const { status: sessionStatus } = useSession(); const [tab, setTab] = useState("overview"); const [isEditing, setIsEditing] = useState(false); const [actionMessage, setActionMessage] = useState(null); const [actionError, setActionError] = useState(null); const { canEdit, canViewCosts } = usePermissions(); + const isPermissionsLoading = sessionStatus === "loading"; const utils = trpc.useUtils(); const detailQuery = trpc.estimate.getById.useQuery( { id: estimateId }, { - enabled: canViewCosts, + enabled: canViewCosts && !isPermissionsLoading, staleTime: 15_000, }, ); @@ -132,10 +135,16 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) const createRevisionMutation = trpc.estimate.createRevision.useMutation(); const createExportMutation = trpc.estimate.createExport.useMutation(); const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation(); + const estimateCommentTarget = { entityType: "estimate" as const, entityId: estimateId }; + const canLoadCommentCount = + canViewCosts + && !isPermissionsLoading + && detailQuery.status === "success" + && detailQuery.data != null; const commentCountQuery = trpc.comment.count.useQuery( - { entityType: "estimate", entityId: estimateId }, - { enabled: canViewCosts, staleTime: 30_000 }, + estimateCommentTarget, + { enabled: canLoadCommentCount, staleTime: 30_000 }, ); const commentCount = commentCountQuery.data ?? 0; @@ -281,7 +290,9 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
- {!canViewCosts ? ( + {isPermissionsLoading ? ( + Loading estimate workspace... + ) : !canViewCosts ? ( Your role can access the estimate list, but not the detailed financial workspace. ) : detailQuery.isLoading ? ( Loading estimate workspace... @@ -364,7 +375,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })

Comments

- +
)} diff --git a/apps/web/src/components/reports/reportBuilderExplainability.test.ts b/apps/web/src/components/reports/reportBuilderExplainability.test.ts new file mode 100644 index 0000000..689243c --- /dev/null +++ b/apps/web/src/components/reports/reportBuilderExplainability.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + buildReportWorkbookSheets, + buildResourceMonthExplainabilitySheetRows, +} from "./reportBuilderExplainability.js"; + +describe("reportBuilderExplainability", () => { + it("builds a readable explainability sheet for resource month reports", () => { + const rows = buildResourceMonthExplainabilitySheetRows( + { + entity: "resource_month", + periodMonth: "2026-01", + locationContextColumns: ["countryCode", "federalState"], + holidayMetricColumns: ["monthlyPublicHolidayCount"], + absenceMetricColumns: ["monthlyAbsenceHoursDeduction"], + capacityMetricColumns: ["monthlyBaseAvailableHours", "monthlySahHours"], + chargeabilityMetricColumns: ["monthlyActualChargeabilityPct"], + missingRecommendedColumns: ["countryName", "monthlyTargetHours"], + notes: ["SAH is holiday-adjusted."], + }, + (column) => ({ + countryCode: "Country Code", + federalState: "Federal State", + monthlyPublicHolidayCount: "Holiday Dates", + monthlyAbsenceHoursDeduction: "Absence Hours Deduction", + monthlyBaseAvailableHours: "Base Available Hours", + monthlySahHours: "SAH", + monthlyActualChargeabilityPct: "Actual Chargeability (%)", + countryName: "Country", + monthlyTargetHours: "Target Hours", + }[column] ?? column), + ); + + expect(rows).toEqual([ + ["Resource Month Explainability"], + ["Period Month", "2026-01"], + ["Location Context Columns", "Country Code", "Federal State"], + ["Holiday Metric Columns", "Holiday Dates"], + ["Absence Metric Columns", "Absence Hours Deduction"], + ["Capacity Metric Columns", "Base Available Hours", "SAH"], + ["Chargeability Metric Columns", "Actual Chargeability (%)"], + ["Missing Recommended Columns", "Country", "Target Hours"], + [], + ["Notes"], + ["SAH is holiday-adjusted."], + ]); + }); + + it("adds grouped report rows and an explainability sheet to workbook output", () => { + const sheets = buildReportWorkbookSheets({ + columns: ["displayName", "monthlySahHours"], + rows: [ + { displayName: "Alice", monthlySahHours: 160 }, + { displayName: "Bob", monthlySahHours: 152 }, + ], + groups: [{ key: "BY", label: "Bayern", rowCount: 2, startIndex: 0 }], + groupBy: "federalState", + explainability: { + entity: "resource_month", + periodMonth: "2026-01", + locationContextColumns: [], + holidayMetricColumns: [], + absenceMetricColumns: [], + capacityMetricColumns: [], + chargeabilityMetricColumns: [], + missingRecommendedColumns: [], + notes: [], + }, + resolveColumnLabel: (column) => ({ + displayName: "Name", + monthlySahHours: "SAH", + federalState: "Federal State", + }[column] ?? column), + }); + + expect(sheets[0]).toEqual({ + name: "Report", + rows: [ + ["Name", "SAH"], + ["Federal State: Bayern (2)", ""], + ["Alice", 160], + ["Bob", 152], + ], + }); + expect(sheets[1]?.name).toBe("Explainability"); + }); +}); diff --git a/apps/web/src/components/reports/reportBuilderExplainability.ts b/apps/web/src/components/reports/reportBuilderExplainability.ts new file mode 100644 index 0000000..2cc4b27 --- /dev/null +++ b/apps/web/src/components/reports/reportBuilderExplainability.ts @@ -0,0 +1,91 @@ +type WorkbookCellValue = boolean | Date | number | string | null | undefined; + +export type ResourceMonthReportExplainability = { + entity: "resource_month"; + periodMonth: string | null; + locationContextColumns: string[]; + holidayMetricColumns: string[]; + absenceMetricColumns: string[]; + capacityMetricColumns: string[]; + chargeabilityMetricColumns: string[]; + missingRecommendedColumns: string[]; + notes: string[]; +}; + +export type ReportExplainability = ResourceMonthReportExplainability; + +export type ReportExportSheet = { + name: string; + rows: WorkbookCellValue[][]; +}; + +type BuildReportWorkbookSheetsInput = { + columns: string[]; + rows: Record[]; + groups: Array<{ key: string; label: string; rowCount: number; startIndex: number }>; + groupBy?: string; + explainability?: ReportExplainability; + resolveColumnLabel: (column: string) => string; +}; + +export function buildResourceMonthExplainabilitySheetRows( + explainability: ReportExplainability, + resolveColumnLabel: (column: string) => string, +): WorkbookCellValue[][] { + const mapLabels = (columns: string[]) => ( + columns.length > 0 ? columns.map(resolveColumnLabel) : ["none"] + ); + + return [ + ["Resource Month Explainability"], + ["Period Month", explainability.periodMonth ?? "current month"], + ["Location Context Columns", ...mapLabels(explainability.locationContextColumns)], + ["Holiday Metric Columns", ...mapLabels(explainability.holidayMetricColumns)], + ["Absence Metric Columns", ...mapLabels(explainability.absenceMetricColumns)], + ["Capacity Metric Columns", ...mapLabels(explainability.capacityMetricColumns)], + ["Chargeability Metric Columns", ...mapLabels(explainability.chargeabilityMetricColumns)], + ["Missing Recommended Columns", ...mapLabels(explainability.missingRecommendedColumns)], + [], + ["Notes"], + ...explainability.notes.map((note) => [note]), + ]; +} + +export function buildReportWorkbookSheets( + input: BuildReportWorkbookSheetsInput, +): ReportExportSheet[] { + const headerRow = input.columns.map(input.resolveColumnLabel); + const groupStartByIndex = new Map( + input.groups.map((group) => [group.startIndex, group] as const), + ); + const groupByLabel = input.groupBy ? input.resolveColumnLabel(input.groupBy) : null; + + const reportRows: WorkbookCellValue[][] = [headerRow]; + input.rows.forEach((row, index) => { + const group = groupStartByIndex.get(index); + if (group && groupByLabel) { + reportRows.push([ + `${groupByLabel}: ${group.label} (${group.rowCount})`, + ...Array.from({ length: Math.max(0, input.columns.length - 1) }, () => ""), + ]); + } + + reportRows.push(input.columns.map((column) => { + const value = row[column]; + if (value === undefined) { + return ""; + } + return value as WorkbookCellValue; + })); + }); + + const sheets: ReportExportSheet[] = [{ name: "Report", rows: reportRows }]; + if (input.explainability?.entity === "resource_month") { + sheets.push({ + name: "Explainability", + rows: buildResourceMonthExplainabilitySheetRows(input.explainability, input.resolveColumnLabel), + }); + } + + return sheets; +} diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx index 39fdc92..af11ce1 100644 --- a/apps/web/src/components/resources/ResourceDetail.tsx +++ b/apps/web/src/components/resources/ResourceDetail.tsx @@ -26,6 +26,7 @@ const SkillMatrixUpload = dynamic( import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { ProgressRing } from "~/components/ui/ProgressRing.js"; import { FadeIn } from "~/components/ui/FadeIn.js"; +import { CommentThread } from "~/components/comments/CommentThread.js"; interface ResourceDetailProps { resourceId: string; @@ -91,6 +92,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { const resource = _resourceQuery.data as unknown as Resource | undefined; const loadingResource = _resourceQuery.isLoading; const error = _resourceQuery.error; + const errorCode = (error as any)?.data?.code as string | undefined; // Fetch allocations for this resource (all non-cancelled) const now = new Date(); @@ -119,6 +121,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { }, { enabled: !!resourceId }, ); + const vacationList = (vacations ?? []) as Array<{ + endDate: Date | string; + id: string; + note?: string | null; + startDate: Date | string; + status: string; + type: string; + }>; const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery( { includeProposed: includeProposedChargeability, resourceId }, @@ -143,7 +153,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { ); } - if (error || !resource) { + if (errorCode === "NOT_FOUND") { return (
@@ -154,6 +164,17 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { ); } + if (error || !resource) { + return ( +
+
+ This resource could not be loaded right now.{" "} + Back to resources +
+
+ ); + } + const skills = resource.skills as unknown as SkillEntry[]; const resourceRoles = (resource as unknown as { resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[]; @@ -433,6 +454,24 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { onGenerated={async () => { await utils.resource.getById.invalidate({ id: resourceId }); }} /> +
+
+

Comments

+

+ Discussion for this resource follows the same visibility as the resource detail itself. +

+
+ +
+ {/* Main Skills Badges */} {mainSkills.length > 0 && (
@@ -594,11 +633,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{loadingVacations ? (
Loading…
- ) : (vacations ?? []).length === 0 ? ( + ) : vacationList.length === 0 ? (
No vacations recorded.
) : (
- {(vacations ?? []).map((v) => { + {vacationList.map((v) => { const days = Math.round( (new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) / (1000 * 60 * 60 * 24), diff --git a/apps/web/src/components/timeline/AllocationPopover.tsx b/apps/web/src/components/timeline/AllocationPopover.tsx index cfd569a..72f8129 100644 --- a/apps/web/src/components/timeline/AllocationPopover.tsx +++ b/apps/web/src/components/timeline/AllocationPopover.tsx @@ -13,11 +13,13 @@ import { DateInput } from "~/components/ui/DateInput.js"; interface AllocationPopoverProps { allocationId: string; projectId: string; + initialAllocation?: AllocationPopoverAssignment | null; onClose: () => void; onOpenPanel: (projectId: string) => void; /** Pixel position relative to the viewport */ anchorX: number; anchorY: number; + contextDate?: Date; } type AllocationPopoverAssignment = Assignment; @@ -25,10 +27,12 @@ type AllocationPopoverAssignment = Assignment; export function AllocationPopover({ allocationId, projectId, + initialAllocation = null, onClose, onOpenPanel, anchorX, anchorY, + contextDate, }: AllocationPopoverProps) { const utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline(); @@ -41,15 +45,22 @@ export function AllocationPopover({ const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery( { projectId }, - { staleTime: 10_000 }, + { staleTime: 10_000, enabled: !initialAllocation }, ) as { data: AllocationReadModel | undefined; isLoading: boolean }; - const allocation = allocationView?.assignments.find((entry) => entry.id === allocationId) as AllocationPopoverAssignment | undefined; + const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => ( + entry.id === allocationId + || entry.entityId === allocationId + || entry.sourceAllocationId === allocationId + || getPlanningEntryMutationId(entry) === allocationId + )) as AllocationPopoverAssignment | undefined; const [hoursPerDay, setHoursPerDay] = useState(null); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); const [includeSaturday, setIncludeSaturday] = useState(false); const [role, setRole] = useState(""); + const [carveStartDate, setCarveStartDate] = useState(""); + const [carveEndDate, setCarveEndDate] = useState(""); useEffect(() => { if (allocation) { @@ -59,8 +70,11 @@ export function AllocationPopover({ const meta = allocation.metadata as { includeSaturday?: boolean } | null; setIncludeSaturday(meta?.includeSaturday ?? false); setRole(allocation.role ?? ""); + const defaultCarveDate = contextDate ? toDateInput(contextDate) : ""; + setCarveStartDate(defaultCarveDate); + setCarveEndDate(defaultCarveDate); } - }, [allocation]); + }, [allocation, contextDate]); const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { @@ -70,6 +84,14 @@ export function AllocationPopover({ }, }); + const carveMutation = trpc.timeline.carveAllocationRange.useMutation({ + onSuccess: () => { + invalidateTimeline(); + void utils.allocation.listView.invalidate(); + onClose(); + }, + }); + function toDateInput(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -89,7 +111,16 @@ export function AllocationPopover({ }); } - if (isLoading || !allocation) { + function handleCarveRange() { + if (!allocation || !carveStartDate || !carveEndDate) return; + carveMutation.mutate({ + allocationId: getPlanningEntryMutationId(allocation), + startDate: new Date(carveStartDate), + endDate: new Date(carveEndDate), + }); + } + + if (isLoading) { const loadingPopover = (
Loading... @@ -98,13 +129,38 @@ export function AllocationPopover({ return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body); } + if (!allocation) { + const missingPopover = ( +
+
Allocation unavailable
+

+ The selected booking could not be resolved from the current timeline data. +

+ +
+ ); + return typeof document === "undefined" ? missingPopover : createPortal(missingPopover, document.body); + } + const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2); + const carveDateRangeInvalid = + Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate; + const popover = (
{/* Header */}
@@ -114,7 +170,7 @@ export function AllocationPopover({
-
+
{/* Resource */}
Resource: {allocation.resource?.displayName} @@ -182,6 +238,9 @@ export function AllocationPopover({ {updateMutation.isError && (

{updateMutation.error.message}

)} + {carveMutation.isError && ( +

{carveMutation.error.message}

+ )} {/* Actions */}
@@ -203,6 +262,57 @@ export function AllocationPopover({
+
+
+
+
Remove Date Range
+
+ {contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."} +
+
+
+ +
+
+ + +
+
+ + +
+
+ + {carveDateRangeInvalid && ( +

End date must be on or after the start date.

+ )} + + +
+ {/* Link to full panel */}
-
+
{/* Date range */}
diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index 67dbee9..f5563da 100644 --- a/apps/web/src/components/timeline/TimelineContext.tsx +++ b/apps/web/src/components/timeline/TimelineContext.tsx @@ -19,6 +19,7 @@ export type TimelineDisplayMode = "strip" | "bar" | "heatmap"; import { addDays } from "./utils.js"; import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js"; import { DONE_STATUSES } from "./timelineConstants.js"; +import { toLocalDateKey } from "./timelineAvailability.js"; // ─── Local timeline types ───────────────────────────────────────────────────── // These re-declare the shapes that the original TimelineView used internally. @@ -133,6 +134,13 @@ export type VacationEntry = { startDate: Date | string; endDate: Date | string; note?: string | null; + scope?: string | null; + calendarName?: string | null; + sourceType?: string | null; + countryCode?: string | null; + countryName?: string | null; + federalState?: string | null; + metroCityName?: string | null; status: string; requestedBy?: { name?: string | null; email: string } | null; approvedBy?: { name?: string | null; email: string } | null; @@ -149,6 +157,13 @@ export type HolidayOverlayEntry = { startDate: Date | string; endDate: Date | string; note?: string | null; + scope?: string | null; + calendarName?: string | null; + sourceType?: string | null; + countryCode?: string | null; + countryName?: string | null; + federalState?: string | null; + metroCityName?: string | null; }; // ─── Context shape ────────────────────────────────────────────────────────── @@ -224,7 +239,7 @@ export function TimelineProvider({ ? ((session.user as { role?: string } | undefined)?.role ?? "USER") : null; const isSelfServiceTimeline = role === "USER" || role === "VIEWER"; - const isRoleLoading = sessionStatus !== "authenticated"; + const isRoleLoading = sessionStatus === "loading"; const today = useMemo(() => { const d = new Date(); @@ -289,7 +304,7 @@ export function TimelineProvider({ const blinkOverbookedDays = appPrefs.blinkOverbookedDays; // ─── Data queries ────────────────────────────────────────────────────────── - const mountedRef = useRef(false); + const initialRefreshKeyRef = useRef(null); const timelineQueryInput = { startDate: viewStart, endDate: viewEnd, @@ -338,13 +353,31 @@ export function TimelineProvider({ const assignments = entriesView?.assignments ?? []; const demands = entriesView?.demands ?? []; - const { - data: vacationEntries = [], - refetch: refetchVacations, - } = trpc.vacation.list.useQuery( + // Avoid TS deep-instantiation blow-ups on the large TRPC hook type here. + const vacationListQuery = trpc.vacation.list.useQuery as unknown as ( + input: { + startDate: Date; + endDate: Date; + status: VacationStatus[]; + limit: number; + }, + options: { + placeholderData: (prev: VacationEntry[] | undefined) => VacationEntry[] | undefined; + refetchOnWindowFocus: boolean; + staleTime: number; + }, + ) => { + data: VacationEntry[] | undefined; + refetch: () => Promise; + }; + const vacationEntriesQuery = vacationListQuery( { startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 }, { placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 }, ); + const { + data: vacationEntries = [], + refetch: refetchVacations, + } = vacationEntriesQuery; const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery( timelineQueryInput, @@ -370,32 +403,63 @@ export function TimelineProvider({ refetch: refetchHolidayOverlays, } = activeHolidayOverlayQuery; - useEffect(() => { - if (mountedRef.current) return; - if (isRoleLoading) return; - mountedRef.current = true; + const initialRefreshKey = useMemo( + () => + JSON.stringify({ + isSelfServiceTimeline, + start: viewStart.toISOString(), + end: viewEnd.toISOString(), + clients: filters.clientIds, + projects: filters.projectIds, + chapters: filters.chapters, + eids: filters.eids, + countries: filters.countryCodes, + }), + [ + filters.chapters, + filters.clientIds, + filters.countryCodes, + filters.eids, + filters.projectIds, + isSelfServiceTimeline, + viewEnd, + viewStart, + ], + ); - // Harden client-side route transitions: the timeline must actively refresh - // its core read models once on mount instead of relying on a prefetched shell. + useEffect(() => { + if (isRoleLoading) return; + if (initialRefreshKeyRef.current === initialRefreshKey) return; + initialRefreshKeyRef.current = initialRefreshKey; + + // Harden client-side route and filter transitions: refresh the core + // read models once per active timeline query context instead of trusting + // prefetched or placeholder state to self-heal. void refetchEntriesView(); void refetchVacations(); void refetchHolidayOverlays(); - }, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]); + }, [ + initialRefreshKey, + isRoleLoading, + refetchEntriesView, + refetchHolidayOverlays, + refetchVacations, + ]); const vacationsByResource = useMemo(() => { const map = new Map(); const mergedEntries = [...(vacationEntries as VacationEntry[])]; const existingKeys = new Set( mergedEntries.map((vacation) => { - const start = new Date(vacation.startDate).toISOString().slice(0, 10); - const end = new Date(vacation.endDate).toISOString().slice(0, 10); + const start = toLocalDateKey(vacation.startDate); + const end = toLocalDateKey(vacation.endDate); return `${vacation.resourceId}:${vacation.type}:${start}:${end}`; }), ); for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) { - const start = new Date(holiday.startDate).toISOString().slice(0, 10); - const end = new Date(holiday.endDate).toISOString().slice(0, 10); + const start = toLocalDateKey(holiday.startDate); + const end = toLocalDateKey(holiday.endDate); const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`; if (existingKeys.has(key)) { continue; diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index c7d10c9..62fa76b 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -9,10 +9,21 @@ import { type TimelineAssignmentEntry, type TimelineDemandEntry, } from "./TimelineContext.js"; +import { + applyPointerOffsetPreviewRect, + applyVisualOverrides, + getDragPointerOffset, + type TimelineVisualOverrides, +} from "./allocationVisualState.js"; import { heatmapColor } from "./heatmapUtils.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { formatDateLong } from "~/lib/format.js"; -import { TimelineTooltip } from "./TimelineTooltip.js"; +import { + TimelineTooltip, + type DemandHoverData, + type HeatmapHoverData, + type VacationHoverData, +} from "./TimelineTooltip.js"; import { ROW_HEIGHT, SUB_LANE_HEIGHT, @@ -24,11 +35,31 @@ import { getProjectColor } from "~/lib/project-colors.js"; import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; import { + buildVacationBlocksByResource, renderVacationBlocks, renderRangeOverlay, renderOverbookingBlink, type VacationBlockInfo, } from "./renderHelpers.js"; +import { + buildDemandHoverData, + cancelHoverFrame, + collectResourcesWithVacations, + scheduleVacationHoverUpdate, + updateTooltipPosition, +} from "./timelineHover.js"; +import { buildResourceHeatmapSeries } from "./timelineHeatmap.js"; +import { buildResourceCapacitySeries } from "./timelineCapacity.js"; +import { + buildProjectRowMetrics, + type ProjectDayMetric, +} from "./timelineProjectMetrics.js"; +import { + buildProjectFlatRows, + estimateProjectRowHeight, + type OpenDemandRowLayout, + type ProjectFlatRow, +} from "./timelineProjectRows.js"; // ─── Props ────────────────────────────────────────────────────────────────── @@ -46,11 +77,13 @@ interface TimelineProjectPanelProps { onOpenPanel: (projectId: string) => void; onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void; onAllocationContextMenu: ( - info: { allocationId: string; projectId: string }, + info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void; multiSelectState: MultiSelectState; + optimisticAllocations: TimelineVisualOverrides; + suppressHoverInteractions: boolean; // Layout from useTimelineLayout CELL_WIDTH: number; dates: Date[]; @@ -82,57 +115,7 @@ export interface OpenDemandAssignment { project?: { id: string; name: string; shortCode: string }; } -type HeatmapBreakdownEntry = { - projectId: string; - shortCode: string; - projectName: string; - orderType: string; - hoursPerDay: number; - responsiblePerson?: string | null; -}; - -type HeatmapHoverState = { - date: Date; - totalH: number; - pct: number; - breakdown: HeatmapBreakdownEntry[]; -}; - -type ProjectDayMetric = { - projH: number; - totalH: number; -}; - -type HeatmapBreakdownAccumulator = { - shortCode: string; - projectName: string; - orderType: string; - responsiblePerson: string | null; - hours: number; -}; - -type ProjectFlatRow = - | { - type: "header"; - key: string; - project: NonNullable["projectGroups"]>[number]; - } - | { - type: "open-demand"; - key: string; - projectId: string; - openDemands: TimelineDemandEntry[]; - } - | { - type: "resource"; - key: string; - project: NonNullable["projectGroups"]>[number]; - resource: NonNullable< - ReturnType["projectGroups"] - >[number]["resourceRows"][number]["resource"]; - allocs: TimelineAssignmentEntry[]; - metricsKey: string; - }; +type HeatmapHoverState = HeatmapHoverData; const EMPTY_DAY_METRICS: ProjectDayMetric[] = []; const SVG_XMLNS = "http://www.w3.org/2000/svg"; @@ -154,6 +137,8 @@ function TimelineProjectPanelInner({ onOpenDemandClick, onAllocationContextMenu, multiSelectState, + optimisticAllocations, + suppressHoverInteractions, CELL_WIDTH, dates, totalCanvasWidth, @@ -175,6 +160,27 @@ function TimelineProjectPanelInner({ today, } = useTimelineContext(); + const visualAllocsByResource = useMemo(() => { + if (optimisticAllocations.size === 0) return allocsByResource; + + const next = new Map(); + for (const [resourceId, allocs] of allocsByResource) { + next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations)); + } + return next; + }, [allocsByResource, optimisticAllocations]); + + const visualProjectGroups = useMemo( + () => projectGroups.map((project) => ({ + ...project, + resourceRows: project.resourceRows.map((row) => ({ + ...row, + allocs: applyVisualOverrides(row.allocs, optimisticAllocations), + })), + })), + [projectGroups, optimisticAllocations], + ); + // ─── Heatmap hover (same mechanism as resource panel) ───────────────────── const heatmapRafRef = useRef(null); const lastHeatmapDayRef = useRef(-1); @@ -193,239 +199,65 @@ function TimelineProjectPanelInner({ const vacationTooltipPosRef = useRef({ left: 0, top: 0 }); const demandTooltipPosRef = useRef({ left: 0, top: 0 }); - const [heatmapHover, setHeatmapHover] = useState<{ - date: Date; - totalH: number; - pct: number; - breakdown: HeatmapBreakdownEntry[]; - } | null>(null); - const [vacationHover, setVacationHover] = useState(null); - const [demandHover, setDemandHover] = useState(null); + const [heatmapHover, setHeatmapHover] = useState(null); + const [vacationHover, setVacationHover] = useState(null); + const [demandHover, setDemandHover] = useState(null); - const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => { - const dateIndexByTime = new Map(); - dates.forEach((date, index) => { - const normalized = new Date(date); - normalized.setHours(0, 0, 0, 0); - dateIndexByTime.set(normalized.getTime(), index); - }); + const resourceCapacityById = useMemo( + () => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates), + [dates, vacationsByResource, visualAllocsByResource], + ); - const nextHeatmapById = new Map(); - const nextTotalHoursById = new Map(); + const { resourceHeatmapById, resourceTotalHoursById } = useMemo( + () => buildResourceHeatmapSeries(visualAllocsByResource, dates, resourceCapacityById), + [dates, resourceCapacityById, visualAllocsByResource], + ); - for (const [resourceId, allocs] of allocsByResource) { - if (allocs.length === 0) continue; - - const totalHours = new Array(dates.length).fill(0); - const breakdownMaps = Array.from({ length: dates.length }, () => new Map()); - - for (const alloc of allocs) { - const current = new Date(alloc.startDate); - current.setHours(0, 0, 0, 0); - const end = new Date(alloc.endDate); - end.setHours(0, 0, 0, 0); - - while (current.getTime() <= end.getTime()) { - const dayIndex = dateIndexByTime.get(current.getTime()); - if (dayIndex !== undefined) { - totalHours[dayIndex] = (totalHours[dayIndex] ?? 0) + alloc.hoursPerDay; - - const dayBreakdown = breakdownMaps[dayIndex]; - if (!dayBreakdown) { - current.setDate(current.getDate() + 1); - continue; - } - - const existing = dayBreakdown.get(alloc.projectId); - if (existing) { - existing.hours += alloc.hoursPerDay; - } else { - dayBreakdown.set(alloc.projectId, { - shortCode: alloc.project.shortCode, - projectName: alloc.project.name, - orderType: alloc.project.orderType, - responsiblePerson: - (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? - null, - hours: alloc.hoursPerDay, - }); - } - } - current.setDate(current.getDate() + 1); - } - } - - nextTotalHoursById.set(resourceId, totalHours); - nextHeatmapById.set( - resourceId, - totalHours.map((totalH, dayIndex) => { - if (totalH === 0) return null; - - const dayBreakdown = breakdownMaps[dayIndex]; - if (!dayBreakdown) return null; - - const breakdown: HeatmapBreakdownEntry[] = [...dayBreakdown.entries()] - .map(([projectId, value]) => ({ - projectId, - shortCode: value.shortCode, - projectName: value.projectName, - orderType: value.orderType, - responsiblePerson: value.responsiblePerson, - hoursPerDay: value.hours, - })) - .sort((a, b) => b.hoursPerDay - a.hoursPerDay); - - return { - date: dates[dayIndex] ?? new Date(), - totalH, - pct: (totalH / 8) * 100, - breakdown, - }; - }), - ); - } - - return { - resourceHeatmapById: nextHeatmapById, - resourceTotalHoursById: nextTotalHoursById, - }; - }, [allocsByResource, dates]); + const vacationBlocksByResource = useMemo( + () => + buildVacationBlocksByResource( + vacationsByResource, + filters.showVacations, + toLeft, + toWidth, + CELL_WIDTH, + totalCanvasWidth, + ), + [CELL_WIDTH, filters.showVacations, toLeft, toWidth, totalCanvasWidth, vacationsByResource], + ); const projectRowMetrics = useMemo(() => { - const dateIndexByTime = new Map(); - dates.forEach((date, index) => { - const normalized = new Date(date); - normalized.setHours(0, 0, 0, 0); - dateIndexByTime.set(normalized.getTime(), index); - }); + return buildProjectRowMetrics( + dates, + visualProjectGroups, + resourceTotalHoursById, + resourceCapacityById, + ); + }, [dates, resourceCapacityById, resourceTotalHoursById, visualProjectGroups]); - const nextMetrics = new Map(); - - for (const project of projectGroups) { - for (const { resource, allocs } of project.resourceRows) { - const projectHours = new Array(dates.length).fill(0); - - for (const alloc of allocs) { - const current = new Date(alloc.startDate); - current.setHours(0, 0, 0, 0); - const end = new Date(alloc.endDate); - end.setHours(0, 0, 0, 0); - - while (current.getTime() <= end.getTime()) { - const dayIndex = dateIndexByTime.get(current.getTime()); - if (dayIndex !== undefined) { - projectHours[dayIndex] = (projectHours[dayIndex] ?? 0) + alloc.hoursPerDay; - } - current.setDate(current.getDate() + 1); - } - } - - const totalHours = resourceTotalHoursById.get(resource.id); - nextMetrics.set( - `${project.id}:${resource.id}`, - projectHours.map((projH, dayIndex) => ({ - projH, - totalH: totalHours?.[dayIndex] ?? 0, - })), - ); - } - } - - return nextMetrics; - }, [dates, projectGroups, resourceTotalHoursById]); - - const flatRows = useMemo(() => { - const rows: ProjectFlatRow[] = []; - - for (const project of projectGroups) { - rows.push({ type: "header", key: `header-${project.id}`, project }); - - const openDemands = openDemandsByProject.get(project.id) ?? []; - if (openDemands.length > 0) { - rows.push({ - type: "open-demand", - key: `open-demand-${project.id}`, - projectId: project.id, - openDemands, - }); - } - - for (const { resource, allocs } of project.resourceRows) { - rows.push({ - type: "resource", - key: `${project.id}-${resource.id}`, - project, - resource, - allocs, - metricsKey: `${project.id}:${resource.id}`, - }); - } - } - - return rows; - }, [openDemandsByProject, projectGroups]); + const flatRows = useMemo( + () => buildProjectFlatRows(visualProjectGroups, openDemandsByProject, optimisticAllocations), + [openDemandsByProject, optimisticAllocations, visualProjectGroups], + ); const rowVirtualizer = useVirtualizer({ count: flatRows.length, getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => { - const row = flatRows[index]; - if (!row) return ROW_HEIGHT; - if (row.type === "header") return PROJECT_HEADER_HEIGHT; - if (row.type === "open-demand") { - const laneCount = assignDemandLanes(row.openDemands).size > 0 - ? Math.max(...assignDemandLanes(row.openDemands).values()) + 1 - : 1; - return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16); - } - return ROW_HEIGHT; - }, + estimateSize: (index) => estimateProjectRowHeight(flatRows[index]), overscan: 8, getItemKey: (index) => flatRows[index]?.key ?? index, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalRowHeight = rowVirtualizer.getTotalSize(); - const resourcesWithVacations = useMemo(() => { - const result = new Set(); - for (const [resourceId, vacations] of vacationsByResource) { - if (vacations.length > 0) { - result.add(resourceId); - } - } - return result; - }, [vacationsByResource]); + const resourcesWithVacations = useMemo( + () => collectResourcesWithVacations(vacationsByResource), + [vacationsByResource], + ); const handleRowHeatmapMove = useCallback( (e: React.MouseEvent, resourceId: string) => { - heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 }; - if (heatmapTooltipRef.current) { - heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`; - heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`; - } + updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52); const rect = e.currentTarget.getBoundingClientRect(); const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH); @@ -477,52 +309,28 @@ function TimelineProjectPanelInner({ return; } - vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 }; - if (vacationTooltipRef.current) { - vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`; - vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`; - } - - const rect = e.currentTarget.getBoundingClientRect(); - const clientX = e.clientX; - if (vacationHoverRafRef.current !== null) return; - - vacationHoverRafRef.current = requestAnimationFrame(() => { - vacationHoverRafRef.current = null; - const date = xToDate(clientX, rect); - date.setHours(0, 0, 0, 0); - const time = date.getTime(); - const resourceVacations = vacationsByResource.get(resourceId) ?? []; - const hit = - resourceVacations.find((vacation) => { - const start = new Date(vacation.startDate); - start.setHours(0, 0, 0, 0); - const end = new Date(vacation.endDate); - end.setHours(0, 0, 0, 0); - return time >= start.getTime() && time <= end.getTime(); - }) ?? null; - - const nextKey = hit ? `${resourceId}:${hit.id}` : null; - if (nextKey === hoveredVacationKeyRef.current) return; - - hoveredVacationKeyRef.current = nextKey; - startTransition(() => { - setVacationHover(hit); - }); + updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8); + scheduleVacationHoverUpdate({ + frameRef: vacationHoverRafRef, + hoveredKeyRef: hoveredVacationKeyRef, + resourceId, + clientX: e.clientX, + rect: e.currentTarget.getBoundingClientRect(), + xToDate, + vacations: vacationsByResource.get(resourceId) ?? [], + onHoverChange: (hit) => { + startTransition(() => { + setVacationHover(hit); + }); + }, }); }, [resourcesWithVacations, vacationsByResource, xToDate], ); const clearHoverTooltips = useCallback(() => { - if (heatmapRafRef.current !== null) { - cancelAnimationFrame(heatmapRafRef.current); - heatmapRafRef.current = null; - } - if (vacationHoverRafRef.current !== null) { - cancelAnimationFrame(vacationHoverRafRef.current); - vacationHoverRafRef.current = null; - } + cancelHoverFrame(heatmapRafRef); + cancelHoverFrame(vacationHoverRafRef); const shouldClearHeatmap = lastHeatmapDayRef.current !== -1; const shouldClearVacation = hoveredVacationKeyRef.current !== null; @@ -543,37 +351,10 @@ function TimelineProjectPanelInner({ const handleDemandHoverMove = useCallback( (e: React.MouseEvent, demand: TimelineDemandEntry) => { - demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 }; - if (demandTooltipRef.current) { - demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`; - demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`; - } - - const startDate = new Date(demand.startDate); - const endDate = new Date(demand.endDate); - const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1); + updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36); startTransition(() => { - setDemandHover({ - roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand", - roleColor: demand.roleEntity?.color ?? "#f59e0b", - projectName: demand.project.name, - projectShortCode: demand.project.shortCode, - requestedHeadcount: demand.requestedHeadcount, - unfilledHeadcount: demand.unfilledHeadcount, - startDate: demand.startDate, - endDate: demand.endDate, - hoursPerDay: demand.hoursPerDay, - totalHours: demand.hoursPerDay * days, - percentage: demand.percentage, - status: demand.status, - ...(demand.dailyCostCents > 0 - ? { - totalCostCents: demand.dailyCostCents * days, - dailyCostCents: demand.dailyCostCents, - } - : {}), - }); + setDemandHover(buildDemandHoverData(demand)); }); }, [], @@ -581,13 +362,18 @@ function TimelineProjectPanelInner({ useEffect( () => () => { - if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current); - if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current); + cancelHoverFrame(heatmapRafRef); + cancelHoverFrame(vacationHoverRafRef); }, [], ); - if (projectGroups.length === 0) { + useEffect(() => { + if (!suppressHoverInteractions) return; + clearHoverTooltips(); + }, [clearHoverTooltips, suppressHoverInteractions]); + + if (visualProjectGroups.length === 0) { return (
No projects in this time range{activeFilterCount > 0 && " (filtered)"}. @@ -677,11 +463,14 @@ function TimelineProjectPanelInner({ {gridLines} {projWidth > 0 && projLeft < totalCanvasWidth && (
{ if (!dragState.isDragging) onOpenPanel(project.id); }} - onMouseDown={(e) => + onMouseDown={(e) => { + if (e.button === 2) { + e.preventDefault(); + e.stopPropagation(); + if (!dragState.isDragging) { + onOpenPanel(project.id); + } + return; + } onProjectBarMouseDown(e, { projectId: project.id, projectName: project.name, startDate: project.startDate, endDate: project.endDate, - }) - } + }); + }} onTouchStart={(e) => onProjectBarTouchStart(e, { projectId: project.id, @@ -709,7 +515,13 @@ function TimelineProjectPanelInner({ endDate: project.endDate, }) } - onContextMenu={(e) => e.preventDefault()} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (!dragState.isDragging) { + onOpenPanel(project.id); + } + }} > {project.name}
@@ -720,7 +532,8 @@ function TimelineProjectPanelInner({ })() ) : row.type === "open-demand" ? ( renderOpenDemandRow( - row.openDemands, + row.openDemandCount, + row.layout, row.projectId, CELL_WIDTH, totalCanvasWidth, @@ -735,6 +548,7 @@ function TimelineProjectPanelInner({ clearHoverTooltips, multiSelectState, allocDragState, + suppressHoverInteractions, ) ) : (
{ + if (suppressHoverInteractions) return; handleRowHeatmapMove(e, row.resource.id); handleRowVacationHover(e, row.resource.id); }} @@ -812,29 +627,20 @@ function TimelineProjectPanelInner({ onAllocTouchStart, onAllocationContextMenu, multiSelectState, + suppressHoverInteractions, )} {filters.showVacations && renderVacationBlocks( - (vacationsByResource.get(row.resource.id) ?? []).reduce( - (acc, v) => { - const vStart = new Date(v.startDate); - const vEnd = new Date(v.endDate); - const left = toLeft(vStart); - const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd)); - if (width > 0 && left < totalCanvasWidth) { - acc.push({ vacation: v, left, width }); - } - return acc; - }, - [], - ), + vacationBlocksByResource.get(row.resource.id) ?? [], ROW_HEIGHT, )} {blinkOverbookedDays && renderOverbookingBlink( - allocsByResource.get(row.resource.id) ?? [], + visualAllocsByResource.get(row.resource.id) ?? [], dates, CELL_WIDTH, + resourceCapacityById.get(row.resource.id)?.capacityHoursByDay, + resourceCapacityById.get(row.resource.id)?.bookingFactorsByDay, )} {renderRangeOverlay( rangeState, @@ -870,41 +676,9 @@ function TimelineProjectPanelInner({ // ─── Pure render functions ────────────────────────────────────────────────── -/** Assign lane indices to demands so overlapping bars don't stack on top of each other. */ -function assignDemandLanes( - demands: TimelineDemandEntry[], -): Map { - const laneMap = new Map(); - // Each lane tracks the latest end-date occupying it - const laneEnds: Date[] = []; - - // Sort by start date for greedy lane assignment - const sorted = [...demands].sort( - (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), - ); - - for (const d of sorted) { - const start = new Date(d.startDate); - let assigned = -1; - for (let i = 0; i < laneEnds.length; i++) { - if (laneEnds[i]! < start) { - assigned = i; - laneEnds[i] = new Date(d.endDate); - break; - } - } - if (assigned === -1) { - assigned = laneEnds.length; - laneEnds.push(new Date(d.endDate)); - } - laneMap.set(d.id, assigned); - } - - return laneMap; -} - function renderOpenDemandRow( - openDemands: TimelineDemandEntry[], + openDemandCount: number, + layout: OpenDemandRowLayout, projectId: string, CELL_WIDTH: number, totalCanvasWidth: number, @@ -915,7 +689,7 @@ function renderOpenDemandRow( onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, onAllocationContextMenu: ( - info: { allocationId: string; projectId: string }, + info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void, @@ -923,12 +697,10 @@ function renderOpenDemandRow( onClearHoverTooltips: () => void, multiSelectState: MultiSelectState, allocDragState: AllocDragState, + suppressHoverInteractions: boolean, ) { - if (openDemands.length === 0) return null; - - const laneMap = assignDemandLanes(openDemands); - const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1; - const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16); + const { visibleOpenDemands, laneMap, rowHeight } = layout; + if (visibleOpenDemands.length === 0) return null; return (
Open demand
- {openDemands.length} open demand{openDemands.length > 1 ? "s" : ""} + {openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
@@ -963,7 +735,7 @@ function renderOpenDemandRow( {rowGridLines}
- {openDemands.map((alloc) => { + {visibleOpenDemands.map((alloc) => { const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); @@ -984,7 +756,26 @@ function renderOpenDemandRow( let left = toLeft(dispStart); let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); - // Clamp negative left (bar starts before view) to avoid extending outside canvas + let dragTransform: string | undefined; + + if (isAllocDragged) { + const preview = applyPointerOffsetPreviewRect({ + left, + width, + mode: allocDragState.mode, + pointerOffsetX: getDragPointerOffset( + allocDragState.pointerDeltaX, + allocDragState.daysDelta, + CELL_WIDTH, + ), + minWidth: CELL_WIDTH, + }); + left = preview.left; + width = preview.width; + dragTransform = preview.transform; + } + + // Clamp negative left (bar starts before view) to avoid extending outside canvas. if (left < 0) { width += left; left = 0; @@ -1025,6 +816,10 @@ function renderOpenDemandRow( return (
{ @@ -1049,15 +850,20 @@ function renderOpenDemandRow( onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); + if (suppressHoverInteractions) return; onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, e.clientY, ); }} - onMouseMove={(e) => onDemandHoverMove(e, alloc)} + onMouseMove={(e) => { + if (suppressHoverInteractions) return; + onDemandHoverMove(e, alloc); + }} onClick={(e) => { e.stopPropagation(); + if (suppressHoverInteractions) return; onOpenDemandClick(alloc, e.clientX, e.clientY); }} onKeyDown={(e) => { @@ -1066,6 +872,7 @@ function renderOpenDemandRow( } e.preventDefault(); e.stopPropagation(); + if (suppressHoverInteractions) return; const rect = e.currentTarget.getBoundingClientRect(); onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2); }} @@ -1136,25 +943,24 @@ function renderProjectUtilOverlay( const BAND_H = 7; const BAR_H = ROW_HEIGHT - BAND_H - 11; - const REF_H = 8; const useHeatmapColors = displayMode === "bar"; const svgParts: string[] = [ ``, ]; - dayMetrics.forEach(({ projH, totalH }, i) => { - if (totalH === 0 && projH === 0) return; + dayMetrics.forEach(({ projH, totalH, capacityH }, i) => { + if ((totalH === 0 && projH === 0) || capacityH <= 0) return; - const isOver = totalH > REF_H; + const isOver = totalH > capacityH; const totalBarH = Math.max( projH > 0 ? 2 : 0, - Math.round((Math.min(totalH, REF_H) / REF_H) * BAR_H), + Math.round((Math.min(totalH, capacityH) / capacityH) * BAR_H), ); const projBarH = - projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / REF_H) * BAR_H))) : 0; + projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / capacityH) * BAR_H))) : 0; const otherBarH = totalBarH - projBarH; - const projPct = (projH / REF_H) * 100; - const totalPct = (totalH / REF_H) * 100; + const projPct = (projH / capacityH) * 100; + const totalPct = (totalH / capacityH) * 100; const projColor = useHeatmapColors ? heatmapColor( projPct, @@ -1229,11 +1035,12 @@ function renderProjectDragHandles( onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, onAllocationContextMenu: ( - info: { allocationId: string; projectId: string }, + info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void, multiSelectState: MultiSelectState, + suppressHoverInteractions: boolean, ) { return allocs.map((alloc) => { const allocStart = new Date(alloc.startDate); @@ -1249,6 +1056,24 @@ function renderProjectDragHandles( let left = toLeft(dispStart); let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); + let dragTransform: string | undefined; + + if (isAllocDragged) { + const preview = applyPointerOffsetPreviewRect({ + left, + width, + mode: allocDragState.mode, + pointerOffsetX: getDragPointerOffset( + allocDragState.pointerDeltaX, + allocDragState.daysDelta, + CELL_WIDTH, + ), + minWidth: CELL_WIDTH, + }); + left = preview.left; + width = preview.width; + dragTransform = preview.transform; + } if (width <= 0 || left >= totalCanvasWidth) return null; // Multi-drag visual offset @@ -1283,6 +1108,10 @@ function renderProjectDragHandles( return (
{ if (e.button === 2) e.stopPropagation(); @@ -1306,6 +1144,7 @@ function renderProjectDragHandles( onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); + if (suppressHoverInteractions) return; onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, @@ -1316,7 +1155,10 @@ function renderProjectDragHandles(
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })} + onMouseDown={(e) => { + e.stopPropagation(); + onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" }); + }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); @@ -1327,7 +1169,10 @@ function renderProjectDragHandles( "flex-1 min-w-0 flex items-center", isAllocDragged ? "cursor-grabbing" : "cursor-grab", )} - onMouseDown={(e) => onAllocMouseDown(e, allocInfo)} + onMouseDown={(e) => { + e.stopPropagation(); + onAllocMouseDown(e, allocInfo); + }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); @@ -1342,7 +1187,10 @@ function renderProjectDragHandles(
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })} + onMouseDown={(e) => { + e.stopPropagation(); + onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); + }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index 7a4c60d..6c9870c 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -7,11 +7,21 @@ import { useTimelineContext, type TimelineAssignmentEntry, } from "./TimelineContext.js"; +import { + applyPointerOffsetPreviewRect, + applyVisualOverrides, + getDragPointerOffset, + type TimelineVisualOverrides, +} from "./allocationVisualState.js"; import { ConflictOverlay } from "./ConflictOverlay.js"; import { computeSubLanes } from "./utils.js"; import { heatmapBgColor } from "./heatmapUtils.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; -import { TimelineTooltip } from "./TimelineTooltip.js"; +import { + TimelineTooltip, + type HeatmapHoverData, + type VacationHoverData, +} from "./TimelineTooltip.js"; import { ROW_HEIGHT, SUB_LANE_HEIGHT, @@ -27,11 +37,27 @@ import type { } from "~/hooks/useTimelineDrag.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; import { + buildVacationBlocksByResource, renderVacationBlocks, renderRangeOverlay, renderOverbookingBlink, type VacationBlockInfo, } from "./renderHelpers.js"; +import { + cancelHoverFrame, + scheduleVacationHoverUpdate, + updateTooltipPosition, +} from "./timelineHover.js"; +import { buildResourceHeatmapHover } from "./timelineHeatmap.js"; +import { + buildResourceCapacitySeries, + type ResourceCapacitySeries, +} from "./timelineCapacity.js"; +import { + buildAllocationWorkingDaySegments, + isAllocationScheduledOnDate, + toLocalDateKey, +} from "./timelineAvailability.js"; // ─── Props ────────────────────────────────────────────────────────────────── @@ -47,11 +73,13 @@ interface TimelineResourcePanelProps { onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void; onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void; onAllocationContextMenu: ( - info: { allocationId: string; projectId: string }, + info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void; multiSelectState: MultiSelectState; + optimisticAllocations: TimelineVisualOverrides; + suppressHoverInteractions: boolean; // Layout from useTimelineLayout CELL_WIDTH: number; dates: Date[]; @@ -71,6 +99,9 @@ export interface AllocMouseDownInfo { resourceId: string | null; startDate: Date; endDate: Date; + allocationStartDate?: Date; + allocationEndDate?: Date; + scope?: "allocation" | "segment"; } export interface RowMouseDownInfo { @@ -94,6 +125,8 @@ function TimelineResourcePanelInner({ onRowTouchStart, onAllocationContextMenu, multiSelectState, + optimisticAllocations, + suppressHoverInteractions, CELL_WIDTH, dates, totalCanvasWidth, @@ -130,33 +163,8 @@ function TimelineResourcePanelInner({ const heatmapTooltipPosRef = useRef({ left: 0, top: 0 }); const vacationTooltipPosRef = useRef({ left: 0, top: 0 }); - const [heatmapHover, setHeatmapHover] = useState<{ - date: Date; - totalH: number; - pct: number; - breakdown: { - projectId: string; - shortCode: string; - projectName: string; - orderType: string; - hoursPerDay: number; - responsiblePerson?: string | null; - role?: string | null; - status?: string; - startDate?: string; - endDate?: string; - }[]; - } | null>(null); - - const [vacationHover, setVacationHover] = useState(null); + const [heatmapHover, setHeatmapHover] = useState(null); + const [vacationHover, setVacationHover] = useState(null); // ─── Virtual row list ──────────────────────────────────────────────────────── const rowVirtualizer = useVirtualizer({ @@ -168,38 +176,45 @@ function TimelineResourcePanelInner({ const virtualItems = rowVirtualizer.getVirtualItems(); const totalRowHeight = rowVirtualizer.getTotalSize(); + const visualAllocsByResource = useMemo(() => { + if (optimisticAllocations.size === 0) return allocsByResource; + + const next = new Map(); + for (const [resourceId, allocs] of allocsByResource) { + next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations)); + } + return next; + }, [allocsByResource, optimisticAllocations]); + + const resourceCapacityById = useMemo( + () => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates), + [dates, vacationsByResource, visualAllocsByResource], + ); + // ─── Memo 1: resourceRows — which rows to render ───────────────────────── // (virtualizer handles which subset is visible; this memo just pre-computes // per-row data that the render loop needs) const resourceRows = useMemo(() => { return resources.map((resource) => { - const allocs = allocsByResource.get(resource.id) ?? []; + const allocs = visualAllocsByResource.get(resource.id) ?? []; const isContextResource = contextResourceIds.includes(resource.id); return { resource, allocs, isContextResource }; }); - }, [resources, allocsByResource, contextResourceIds]); + }, [resources, visualAllocsByResource, contextResourceIds]); // ─── Memo 2: vacationBlocks — vacation bar positions per resource ───────── - const vacationBlocksByResource = useMemo(() => { - if (!filters.showVacations) return new Map(); - - const result = new Map(); - for (const [resourceId, vacations] of vacationsByResource) { - const blocks: VacationBlockInfo[] = []; - for (const v of vacations) { - const vStart = new Date(v.startDate); - const vEnd = new Date(v.endDate); - const left = toLeft(vStart); - const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd)); - if (width <= 0 || left >= totalCanvasWidth) continue; - blocks.push({ vacation: v, left, width }); - } - if (blocks.length > 0) { - result.set(resourceId, blocks); - } - } - return result; - }, [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations]); + const vacationBlocksByResource = useMemo( + () => + buildVacationBlocksByResource( + vacationsByResource, + filters.showVacations, + toLeft, + toWidth, + CELL_WIDTH, + totalCanvasWidth, + ), + [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations], + ); // ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ── // (Bar mode computes differently per-day, so we only pre-compute for strip.) @@ -230,42 +245,39 @@ function TimelineResourcePanelInner({ // ─── Memo 4: utilization per resource for row background tint ─────────── const utilizationByResource = useMemo(() => { - const REF_H = 8; const result = new Map(); // resourceId -> avg utilization pct for (const { resource, allocs } of resourceRows) { if (allocs.length === 0) continue; - let totalHours = 0; + const capacity = resourceCapacityById.get(resource.id); + let totalPct = 0; let dayCount = 0; - for (const date of dates) { - const t = date.getTime(); + for (let dayIndex = 0; dayIndex < dates.length; dayIndex++) { + const date = dates[dayIndex]; + if (!date) continue; + const bookingFactor = capacity?.bookingFactorsByDay[dayIndex] ?? 1; + const capacityHours = capacity?.capacityHoursByDay[dayIndex] ?? 8; let dayH = 0; for (const a of allocs) { - const s = new Date(a.startDate); - s.setHours(0, 0, 0, 0); - const e = new Date(a.endDate); - e.setHours(0, 0, 0, 0); - if (t >= s.getTime() && t <= e.getTime()) dayH += a.hoursPerDay; + if (isAllocationScheduledOnDate(a, date)) { + dayH += a.hoursPerDay * bookingFactor; + } } - if (dayH > 0) { - totalHours += dayH; + if (dayH > 0 && capacityHours > 0) { + totalPct += (dayH / capacityHours) * 100; dayCount++; } } if (dayCount > 0) { - result.set(resource.id, (totalHours / dayCount / REF_H) * 100); + result.set(resource.id, totalPct / dayCount); } } return result; - }, [resourceRows, dates]); + }, [dates, resourceCapacityById, resourceRows]); // ─── Heatmap row hover handler ──────────────────────────────────────────── const handleRowHeatmapMove = useCallback( (e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => { - heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 }; - if (heatmapTooltipRef.current) { - heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`; - heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`; - } + updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52); const rect = e.currentTarget.getBoundingClientRect(); const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH); if (dayIndex === lastHeatmapDayRef.current) return; @@ -289,109 +301,49 @@ function TimelineResourcePanelInner({ } lastHeatmapDayRef.current = dayIdx; - const t = date.getTime(); - const REF_H = 8; - const projectHours = new Map< - string, - { - shortCode: string; - projectName: string; - orderType: string; - hours: number; - responsiblePerson?: string | null; - role?: string | null; - status?: string; - startDate?: string; - endDate?: string; - } - >(); - for (const alloc of a) { - const s = new Date(alloc.startDate); - s.setHours(0, 0, 0, 0); - const ev = new Date(alloc.endDate); - ev.setHours(0, 0, 0, 0); - if (t < s.getTime() || t > ev.getTime()) continue; - const existing = projectHours.get(alloc.projectId); - if (existing) { - existing.hours += alloc.hoursPerDay; - } else { - projectHours.set(alloc.projectId, { - shortCode: alloc.project.shortCode, - projectName: alloc.project.name, - orderType: alloc.project.orderType, - hours: alloc.hoursPerDay, - responsiblePerson: - (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null, - role: alloc.role ?? alloc.roleEntity?.name ?? null, - status: alloc.status, - startDate: new Date(alloc.startDate).toISOString().slice(0, 10), - endDate: new Date(alloc.endDate).toISOString().slice(0, 10), - }); - } - } - - const breakdown = [...projectHours.entries()] - .map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours })) - .sort((a, b) => b.hoursPerDay - a.hoursPerDay); - - const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0); + const resourceId = a[0]?.resourceId ?? null; + const capacity = resourceId ? resourceCapacityById.get(resourceId) : undefined; + const nextHeatmap = buildResourceHeatmapHover(date, a, { + ...(capacity?.capacityHoursByDay[dayIdx] !== undefined + ? { capacityHours: capacity.capacityHoursByDay[dayIdx] } + : {}), + ...(capacity?.bookingFactorsByDay[dayIdx] !== undefined + ? { bookingFactor: capacity.bookingFactorsByDay[dayIdx] } + : {}), + }); startTransition(() => { - setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown }); + setHeatmapHover(nextHeatmap); }); }); }, - [CELL_WIDTH, dates], + [CELL_WIDTH, dates, resourceCapacityById], ); // ─── Vacation hover ─────────────────────────────────────────────────────── const handleRowVacationHover = useCallback( (e: React.MouseEvent, resourceId: string) => { - vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 }; - if (vacationTooltipRef.current) { - vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`; - vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`; - } - const rect = e.currentTarget.getBoundingClientRect(); - const clientX = e.clientX; - - if (vacationHoverRafRef.current !== null) return; - - vacationHoverRafRef.current = requestAnimationFrame(() => { - vacationHoverRafRef.current = null; - const date = xToDate(clientX, rect); - date.setHours(0, 0, 0, 0); - const t = date.getTime(); - const resourceVacations = vacationsByResource.get(resourceId) ?? []; - const hit = - resourceVacations.find((v) => { - const s = new Date(v.startDate); - s.setHours(0, 0, 0, 0); - const end = new Date(v.endDate); - end.setHours(0, 0, 0, 0); - return t >= s.getTime() && t <= end.getTime(); - }) ?? null; - - const nextKey = hit ? `${resourceId}:${hit.id}` : null; - if (nextKey === hoveredVacationKeyRef.current) return; - - hoveredVacationKeyRef.current = nextKey; - startTransition(() => { - setVacationHover(hit); - }); + updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8); + scheduleVacationHoverUpdate({ + frameRef: vacationHoverRafRef, + hoveredKeyRef: hoveredVacationKeyRef, + resourceId, + clientX: e.clientX, + rect: e.currentTarget.getBoundingClientRect(), + xToDate, + vacations: vacationsByResource.get(resourceId) ?? [], + onHoverChange: (hit) => { + startTransition(() => { + setVacationHover(hit); + }); + }, }); }, [vacationsByResource, xToDate], ); const clearHoverTooltips = useCallback(() => { - if (heatmapRafRef.current !== null) { - cancelAnimationFrame(heatmapRafRef.current); - heatmapRafRef.current = null; - } - if (vacationHoverRafRef.current !== null) { - cancelAnimationFrame(vacationHoverRafRef.current); - vacationHoverRafRef.current = null; - } + cancelHoverFrame(heatmapRafRef); + cancelHoverFrame(vacationHoverRafRef); const shouldClearHeatmap = lastHeatmapDayRef.current !== -1; const shouldClearVacation = hoveredVacationKeyRef.current !== null; @@ -410,12 +362,17 @@ function TimelineResourcePanelInner({ // ─── Cleanup rAF on unmount ─────────────────────────────────────────────── useEffect( () => () => { - if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current); - if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current); + cancelHoverFrame(heatmapRafRef); + cancelHoverFrame(vacationHoverRafRef); }, [], ); + useEffect(() => { + if (!suppressHoverInteractions) return; + clearHoverTooltips(); + }, [clearHoverTooltips, suppressHoverInteractions]); + // ─── Render helpers ─────────────────────────────────────────────────────── if (resources.length === 0) { @@ -512,6 +469,7 @@ function TimelineResourcePanelInner({ onRowTouchStart(e, { resourceId: resource.id, startDate: date }); }} onMouseMove={(e) => { + if (suppressHoverInteractions) return; handleRowHeatmapMove(e, allocs); handleRowVacationHover(e, resource.id); }} @@ -531,7 +489,9 @@ function TimelineResourcePanelInner({ toLeft, toWidth, totalCanvasWidth, + resourceCapacityById.get(resource.id), multiSelectState, + suppressHoverInteractions, ) : renderAllocBlocksFromData( precomputed?.blockData ?? [], @@ -546,17 +506,36 @@ function TimelineResourcePanelInner({ onAllocTouchStart, onAllocationContextMenu, multiSelectState, + suppressHoverInteractions, )} {filters.showVacations && renderVacationBlocks( vacationBlocksByResource.get(resource.id) ?? [], rowHeight, )} - {displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)} + {displayMode === "strip" && + renderLoadGraph( + allocs, + dates, + CELL_WIDTH, + resourceCapacityById.get(resource.id), + )} {displayMode === "heatmap" && - renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)} + renderHeatmapOverlay( + allocs, + dates, + CELL_WIDTH, + heatmapScheme, + resourceCapacityById.get(resource.id), + )} {blinkOverbookedDays && - renderOverbookingBlink(allocs, dates, CELL_WIDTH)} + renderOverbookingBlink( + allocs, + dates, + CELL_WIDTH, + resourceCapacityById.get(resource.id)?.capacityHoursByDay, + resourceCapacityById.get(resource.id)?.bookingFactorsByDay, + )} {renderRangeOverlay( rangeState, resource.id, @@ -627,31 +606,57 @@ function renderAllocBlocksFromData( onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, onAllocationContextMenu: ( - info: { allocationId: string; projectId: string }, + info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void, multiSelectState: MultiSelectState, + suppressHoverInteractions: boolean, ) { const anyDragActive = dragState.isDragging || allocDragState.isActive; - return blockData.map(({ alloc, lane }) => { - const allocStart = new Date(alloc.startDate); - const allocEnd = new Date(alloc.endDate); + function toUtcDay(value: Date): Date { + return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate())); + } + + function addUtcDays(value: Date, days: number): Date { + const next = new Date(value); + next.setUTCDate(next.getUTCDate() + days); + return next; + } + + function resolveSegmentContextDate( + clientX: number, + rect: DOMRect, + segmentStart: Date, + segmentEnd: Date, + ): Date { + const start = toUtcDay(segmentStart); + const end = toUtcDay(segmentEnd); + const rawIndex = Math.floor((clientX - rect.left) / CELL_WIDTH); + const maxIndex = Math.max( + 0, + Math.round((end.getTime() - start.getTime()) / 86_400_000), + ); + const dayIndex = Math.min(Math.max(rawIndex, 0), maxIndex); + return addUtcDays(start, dayIndex); + } + + function sameDate(a: Date | null, b: Date | null) { + return Boolean(a && b) && a!.getTime() === b!.getTime(); + } + + return blockData.flatMap(({ alloc, lane }) => { + const allocStart = toUtcDay(new Date(alloc.startDate)); + const allocEnd = toUtcDay(new Date(alloc.endDate)); const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId; - const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; - const isBeingDragged = isProjectShifted || isAllocDragged; - const isOtherDragged = anyDragActive && !isBeingDragged; let dispStart = allocStart; let dispEnd = allocEnd; if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) { dispStart = dragState.currentStartDate; dispEnd = dragState.currentEndDate; - } else if (isAllocDragged && allocDragState.currentStartDate && allocDragState.currentEndDate) { - dispStart = allocDragState.currentStartDate; - dispEnd = allocDragState.currentEndDate; } // Multi-drag offset: shift selected allocations visually during multi-drag @@ -661,25 +666,12 @@ function renderAllocBlocksFromData( const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; const multiDragMode = multiSelectState.multiDragMode; - let left = toLeft(dispStart); - let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); - - // For multi-drag resize, adjust left/width instead of using translateX - if (isMultiDragTarget && multiDragMode === "resize-start") { - left += multiDragPx; - width = Math.max(CELL_WIDTH, width - multiDragPx); - } else if (isMultiDragTarget && multiDragMode === "resize-end") { - width = Math.max(CELL_WIDTH, width + multiDragPx); - } - if (width <= 0 || left >= totalCanvasWidth) return null; - const blockTop = 8 + lane * SUB_LANE_HEIGHT; const blockHeight = SUB_LANE_HEIGHT - 8; const customColor = (alloc.project as { color?: string | null }).color; const projectColor = getProjectColor(alloc.projectId); const blockBgColor = customColor ?? projectColor.hex + "B3"; - const HANDLE_W = width >= 48 ? 10 : 6; const hasRecurrence = !!(alloc.metadata as Record | null)?.recurrence; const allocInfo: AllocMouseDownInfo = { @@ -691,123 +683,257 @@ function renderAllocBlocksFromData( resourceId: alloc.resourceId, startDate: allocStart, endDate: allocEnd, + allocationStartDate: allocStart, + allocationEndDate: allocEnd, + scope: "allocation", }; - return ( -
{ - // Stop right-click mouseDown from bubbling to the canvas, - // which would falsely start a multi-selection rectangle. - if (e.button === 2) e.stopPropagation(); - }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - onAllocationContextMenu( - { allocationId: alloc.id, projectId: alloc.projectId }, - e.clientX, - e.clientY, - ); - }} - > - {/* Left resize handle */} -
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })} - onTouchStart={(e) => { - e.stopPropagation(); - onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); - }} - > - {HANDLE_W >= 10 && ( -
-
-
-
- )} -
- - {/* Center -- move */} -
onAllocMouseDown(e, allocInfo)} - onTouchStart={(e) => { - e.stopPropagation(); - onAllocTouchStart(e, allocInfo); - }} - > - {hasRecurrence && width > 28 && ( - - )} - {width > 60 ? ( - {alloc.project.name} - ) : ( - {alloc.project.shortCode} - )} - {width > 130 && {alloc.role}} - {width > 190 && ( - {alloc.hoursPerDay}h - )} -
- - {/* Right resize handle */} -
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })} - onTouchStart={(e) => { - e.stopPropagation(); - onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); - }} - > - {HANDLE_W >= 10 && ( -
-
-
-
- )} -
-
+ const segments = buildAllocationWorkingDaySegments( + { ...alloc, startDate: dispStart, endDate: dispEnd }, + dispStart, + dispEnd, ); + + if (segments.length === 0) { + return []; + } + + return segments.flatMap((segment, segmentIndex) => { + const isFirstSegment = segmentIndex === 0; + const segmentKey = `${alloc.id}-${segmentIndex}`; + const draggedSegmentActive = + allocDragState.isActive && + allocDragState.allocationId === alloc.id && + sameDate(allocDragState.originalStartDate, toUtcDay(segment.start)) && + sameDate(allocDragState.originalEndDate, toUtcDay(segment.end)); + const isBeingDragged = isProjectShifted || draggedSegmentActive; + const isOtherDragged = anyDragActive && !isBeingDragged; + + let segmentLeft = toLeft(segment.start); + let segmentWidth = Math.max(CELL_WIDTH, toWidth(segment.start, segment.end)); + let dragTransform: string | undefined; + + if (isProjectShifted) { + const preview = applyPointerOffsetPreviewRect({ + left: segmentLeft, + width: segmentWidth, + mode: "move", + pointerOffsetX: getDragPointerOffset( + dragState.pointerDeltaX, + dragState.daysDelta, + CELL_WIDTH, + ), + minWidth: CELL_WIDTH, + }); + segmentLeft = preview.left; + segmentWidth = preview.width; + dragTransform = preview.transform; + } else if (draggedSegmentActive) { + const preview = applyPointerOffsetPreviewRect({ + left: segmentLeft, + width: segmentWidth, + mode: allocDragState.mode, + pointerOffsetX: getDragPointerOffset( + allocDragState.pointerDeltaX, + allocDragState.daysDelta, + CELL_WIDTH, + ), + minWidth: CELL_WIDTH, + }); + segmentLeft = preview.left; + segmentWidth = preview.width; + dragTransform = preview.transform; + } + + if (isMultiDragTarget && multiDragMode === "resize-start") { + segmentLeft += multiDragPx; + segmentWidth = Math.max(CELL_WIDTH, segmentWidth - multiDragPx); + } else if (isMultiDragTarget && multiDragMode === "resize-end") { + segmentWidth = Math.max(CELL_WIDTH, segmentWidth + multiDragPx); + } + + if (segmentWidth <= 0 || segmentLeft >= totalCanvasWidth) { + return []; + } + + const handleWidth = segmentWidth >= 48 ? 10 : 6; + const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4))); + const segmentInfo: AllocMouseDownInfo = { + ...allocInfo, + startDate: toUtcDay(segment.start), + endDate: toUtcDay(segment.end), + scope: "segment", + }; + + return ( +
{ + if (e.button === 2) e.stopPropagation(); + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (suppressHoverInteractions) return; + onAllocationContextMenu( + { + allocationId: alloc.id, + projectId: alloc.projectId, + contextDate: resolveSegmentContextDate( + e.clientX, + e.currentTarget.getBoundingClientRect(), + segment.start, + segment.end, + ), + }, + e.clientX, + e.clientY, + ); + }} + > +
{ + e.stopPropagation(); + onAllocMouseDown(e, { ...segmentInfo, mode: "resize-start" }); + }} + onTouchStart={(e) => { + e.stopPropagation(); + onAllocTouchStart(e, { ...segmentInfo, mode: "resize-start" }); + }} + > + {handleWidth >= 10 && ( +
+
+
+
+ )} +
+ +