From a71bbeb640b706c3cd2793464cc82fdafe2e84d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 14:41:03 +0200 Subject: [PATCH] fix(timeline): stabilize overlay lifecycle --- apps/web/e2e/timeline.spec.ts | 64 +++++-- .../src/components/timeline/TimelineView.tsx | 21 +++ apps/web/src/hooks/useViewportPopover.ts | 12 ++ docs/showcase-execution-batches.md | 175 ++++++++++++++++++ 4 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 docs/showcase-execution-batches.md diff --git a/apps/web/e2e/timeline.spec.ts b/apps/web/e2e/timeline.spec.ts index afa90cc..a77d1a1 100644 --- a/apps/web/e2e/timeline.spec.ts +++ b/apps/web/e2e/timeline.spec.ts @@ -438,20 +438,22 @@ async function openAllocationContextMenuAtOffset( 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"); - } + const target = await resolveAllocationContextMenuTarget(locator); + const box = await readBoundingBox(target); - await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" }); + await page.mouse.click( + box.x + Math.min(Math.max(xOffset, 2), Math.max(box.width - 2, 2)), + box.y + box.height / 2, + { button: "right" }, + ); } async function openContextMenuAtCenter( page: Page, locator: ReturnType, ) { - const box = await readBoundingBox(locator); + const target = await resolveAllocationContextMenuTarget(locator); + const box = await readBoundingBox(target); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" }); } @@ -500,6 +502,11 @@ async function readBoundingBox(locator: ReturnType) { return box; } +async function resolveAllocationContextMenuTarget(locator: ReturnType) { + const interactionTarget = locator.locator("[data-allocation-interaction='body']").first(); + return (await interactionTarget.count()) > 0 ? interactionTarget : locator; +} + async function listRenderedAllocationSegments( row: ReturnType, allocationId?: string, @@ -588,6 +595,16 @@ async function findVisibleAllocationSegmentForResize( selector: string, ): Promise { return page.locator(selector).evaluateAll((elements) => { + const scrollContainer = document.querySelector( + "div.app-surface.relative.z-0.flex-1.overflow-auto", + ); + const stickyHeaderBottom = scrollContainer + ? Array.from(scrollContainer.querySelectorAll(".sticky.top-0")).reduce( + (maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom), + 0, + ) + : 0; + const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48; const candidates: Array<{ allocationId: string; segmentStart: string | null; @@ -608,7 +625,7 @@ async function findVisibleAllocationSegmentForResize( rect.height >= 10 && rect.right > 48 && rect.left < window.innerWidth - 48 && - rect.bottom > 0 && + rect.bottom > safeTop && rect.top < window.innerHeight; if (!isVisible) { @@ -625,7 +642,8 @@ async function findVisibleAllocationSegmentForResize( rect.width >= 36 && rect.width <= 220 && rect.left >= 48 && - rect.right <= window.innerWidth - 48; + rect.right <= window.innerWidth - 48 && + rect.top >= safeTop; if (!isPreferred) { continue; @@ -1197,6 +1215,26 @@ test.describe("Timeline", () => { await expect(cancelButton).not.toBeVisible(); }); + test("timeline allocation popovers close cleanly across view switches", async ({ page }) => { + const allocation = page + .locator("[data-timeline-entry-type='allocation'][data-allocation-id]") + .first(); + await expect(allocation).toBeVisible(); + await openContextMenuAtCenter(page, allocation); + + const allocationPopover = page.getByTestId("timeline-allocation-popover"); + await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { + timeout: 2_000, + }); + await expect(allocationPopover).toBeVisible(); + + await switchToProjectView(page); + await expect(allocationPopover).toHaveCount(0); + await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0); + await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0); + await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0); + }); + test("project bars stay attached to the pointer during fast drag", async ({ page }) => { await switchToProjectView(page); await expect(page.locator("[data-timeline-entry-type='project-bar']").first()).toBeVisible(); @@ -1351,7 +1389,7 @@ test.describe("Timeline", () => { expect(rightResizeAssignments).toHaveLength(1); expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId); expect(rightResizeAssignments[0]?.startDate).toBe("2026-04-06"); - expect(rightResizeAssignments[0]?.endDate > "2026-04-17").toBe(true); + expect(rightResizeAssignments[0]!.endDate > "2026-04-17").toBe(true); const resizeStart = await measureAllocationResizeStartGap(page, projectAllocationSelector); expect(resizeStart.widthGain).toBeGreaterThan(48); @@ -1374,7 +1412,7 @@ test.describe("Timeline", () => { .not.toBe("2026-04-06"); expect(leftResizeAssignments).toHaveLength(1); expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId); - expect(leftResizeAssignments[0]?.startDate < "2026-04-06").toBe(true); + expect(leftResizeAssignments[0]!.startDate < "2026-04-06").toBe(true); expect(leftResizeAssignments[0]?.endDate).toBe(rightResizeAssignments[0]?.endDate); } finally { cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); @@ -1429,7 +1467,7 @@ test.describe("Timeline", () => { expect(rightResizeDemands).toHaveLength(1); expect(rightResizeDemands[0]?.id).toBe(scenario.demandId); expect(rightResizeDemands[0]?.startDate).toBe("2026-04-07"); - expect(rightResizeDemands[0]?.endDate > "2026-04-16").toBe(true); + expect(rightResizeDemands[0]!.endDate > "2026-04-16").toBe(true); expect(rightResizeDemands[0]?.headcount).toBe(2); expect(rightResizeDemands[0]?.status).toBe("PROPOSED"); @@ -1461,7 +1499,7 @@ test.describe("Timeline", () => { .not.toBe("2026-04-07"); expect(leftResizeDemands).toHaveLength(1); expect(leftResizeDemands[0]?.id).toBe(scenario.demandId); - expect(leftResizeDemands[0]?.startDate < "2026-04-07").toBe(true); + expect(leftResizeDemands[0]!.startDate < "2026-04-07").toBe(true); expect(leftResizeDemands[0]?.endDate).toBe(rightResizeDemands[0]?.endDate); expect(leftResizeDemands[0]?.headcount).toBe(2); expect(leftResizeDemands[0]?.status).toBe("PROPOSED"); diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 6e80734..2097a2c 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -355,6 +355,7 @@ function TimelineViewContent({ anchorEl: HTMLElement; } | null>(null); const resourceHoverTimerRef = useRef | null>(null); + const previousViewModeRef = useRef(viewMode); const invalidateTimelineInner = useInvalidateTimeline(); const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({ @@ -386,6 +387,26 @@ function TimelineViewContent({ setResourceHover(null); }, [hasActivePointerOverlay]); + useEffect(() => { + if (previousViewModeRef.current === viewMode) { + return; + } + + previousViewModeRef.current = viewMode; + setPopover(null); + setDemandPopover(null); + setNewAllocPopover(null); + setResourceHover(null); + }, [viewMode, setNewAllocPopover]); + + useEffect(() => { + if (!isInitialLoading) return; + setPopover(null); + setDemandPopover(null); + setNewAllocPopover(null); + setResourceHover(null); + }, [isInitialLoading, setNewAllocPopover]); + // ─── Keep selection overlay visible while popover is open ─────────────────── const effectiveRangeState: typeof rangeState = rangeState.isSelecting ? rangeState diff --git a/apps/web/src/hooks/useViewportPopover.ts b/apps/web/src/hooks/useViewportPopover.ts index d465b57..45eeb2a 100644 --- a/apps/web/src/hooks/useViewportPopover.ts +++ b/apps/web/src/hooks/useViewportPopover.ts @@ -155,6 +155,8 @@ export function useViewportPopover({ }, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]); useEffect(() => { + const closeOnViewportChange = anchor.kind === "point"; + function cancelScheduledFrame() { if (frameRef.current === null) return; cancelAnimationFrame(frameRef.current); @@ -181,9 +183,19 @@ export function useViewportPopover({ updateOrClose(); const handleScroll = () => { + if (closeOnViewportChange) { + cancelScheduledFrame(); + onClose(); + return; + } scheduleUpdate("scroll"); }; const handleResize = () => { + if (closeOnViewportChange) { + cancelScheduledFrame(); + onClose(); + return; + } scheduleUpdate("resize"); }; diff --git a/docs/showcase-execution-batches.md b/docs/showcase-execution-batches.md new file mode 100644 index 0000000..f76c66f --- /dev/null +++ b/docs/showcase-execution-batches.md @@ -0,0 +1,175 @@ +# Showcase Execution Batches + +**Date:** 2026-04-01 +**Purpose:** Canonical execution list for the remaining work to turn CapaKraken into a clean, deterministic, reviewable reference project for disciplined AI-assisted engineering. + +## How To Use This List + +- Work top to bottom unless a hard blocker forces resequencing. +- Keep slices small enough to verify and commit cleanly. +- Every touched slice must add at least one non-happy-path regression. +- Do not broaden scope while the worktree is dirty. +- A batch is done only when code, tests, and documentation for that slice are aligned. + +## Batch Status Legend + +- `todo`: not started +- `active`: current execution lane +- `blocked`: waiting on a hard external dependency or product decision +- `done`: implemented, verified, and committed + +## Batch 1: Timeline Stability And Interaction Discipline + +Status: `active` +Why first: +the timeline is the highest-risk UX surface and still not boringly reliable enough for the showcase goal. + +Progress note: +- 2026-04-01: overlay cleanup on timeline `viewMode` changes and initial-loading transitions landed with targeted e2e regression coverage for allocation popovers across view switches. +- 2026-04-01: viewport-change behavior was tightened so point-anchored timeline popovers close on scroll/resize, while element-anchored hover cards remain repositionable; the viewport regression now passes with a non-happy-path e2e. + +Slices: + +- `todo` stabilize popover and hover behavior across scroll, resize, reload, and view switches +- `todo` verify drag, resize, and selection interactions do not compete under rapid input changes +- `todo` verify SSE first-load, reconnect, and live-update behavior under churn +- `todo` close explicit edge/failure test gaps in timeline e2e and targeted component tests +- `todo` continue shrinking [TimelineView.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/timeline/TimelineView.tsx) into narrower ownership units without changing behavior + +Done criteria: + +- timeline overlays stay anchored under scroll and resize +- timeline view switching and first-load state are deterministic +- drag and selection paths remain stable under quick repeated interactions +- SSE reconnects do not leave stale or empty timeline state +- touched timeline slices have non-happy-path regression coverage + +## Batch 2: Holiday/Vacation Parity And Explainability + +Status: `todo` +Why next: +it is a cross-cutting correctness area that benefits from the same deterministic test discipline without expanding architecture too early. + +Slices: + +- `todo` identify remaining parity gaps between API, UI, assistant, widgets, and exports +- `todo` unify holiday- and vacation-aware forecast derivations where outputs still diverge +- `todo` standardize compact vs detailed explainability surfaces +- `todo` add regression coverage for mismatch and boundary scenarios, not only nominal calculations + +Done criteria: + +- the same holiday and vacation basis is visible across all user-facing surfaces +- explainability is consistent and concise by default +- parity is backed by targeted cross-surface tests + +## Batch 3: Resource Read Decomposition And Smaller Ownership Surfaces + +Status: `todo` +Why next: +this is the lowest-conflict structural cleanup explicitly called out as ready in the API backlog. + +Slices: + +- `todo` split resource read orchestration out of `resource-read-shared.ts` into focused read helpers without changing API contracts +- `todo` keep router files thin and procedure-support focused +- `todo` continue breaking down large frontend ownership surfaces, starting with [ProjectWizard.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/projects/ProjectWizard.tsx) +- `todo` add focused tests around extracted helpers before broadening further + +Done criteria: + +- `resource-read-shared.ts` no longer acts as a monolithic read hotspot +- extracted helpers have narrow responsibilities and direct tests +- large frontend modules have smaller reviewable ownership seams + +## Batch 4: Notification, Task, And Broadcast Reliability Residuals + +Status: `todo` +Why now: +this is important operational hardening, but below timeline and read-surface cleanup in immediate risk. + +Slices: + +- `todo` identify remaining missing-reference and retry edge cases +- `todo` stabilize error translation so failures stay domain-shaped and predictable +- `todo` narrow orchestration helpers where the current flow still mixes concerns +- `todo` add regression coverage for retry, missing-target, and partial-failure scenarios + +Done criteria: + +- reminder, task, and broadcast flows fail cleanly under race and retry conditions +- errors stay stable for callers +- touched orchestration surfaces are smaller and easier to review + +## Batch 5: CI Guardrails And Measurable Quality Gates + +Status: `todo` +Why now: +the goal is a reference project where quality is enforced by CI, not by memory or chat history. + +Slices: + +- `todo` add file-size or ownership-surface checks for the next critical hotspots +- `todo` strengthen auth and permission regression coverage around sensitive routers +- `todo` add package-level quality gates beyond generic pass/fail test runs +- `todo` add bundle or route-size monitoring where growth risk is already known + +Done criteria: + +- CI fails on structural regressions, not only syntax and test failures +- sensitive permission surfaces have explicit regression protection +- critical source hotspots are under measurable guardrails + +## Batch 6: Operational Standardization + +Status: `todo` +Why now: +one production path and one rollback path are core to the showcase goal. + +Slices: + +- `todo` make Redis-backed rate limiting the intentional deployed default +- `todo` document one deploy path and one rollback path with verification steps +- `todo` finish runtime-secret and environment-backed configuration discipline +- `todo` verify the documented path against the current CI/CD and deploy docs + +Done criteria: + +- production deployment and rollback are documented as single canonical flows +- runtime config sources are explicit and consistent +- operational defaults match the documented architecture + +## Batch 7: Reference Project Artifacts + +Status: `todo` +Why last: +these artifacts should capture the standards proven by the earlier technical batches. + +Slices: + +- `todo` add `docs/engineering-doctrine.md` +- `todo` add `docs/ai-collaboration-standards.md` +- `todo` document one exemplary frontend slice and one exemplary backend slice end to end +- `todo` align those docs with the actual CI, testing, and ownership guardrails in the repo + +Done criteria: + +- the repository explains not only what was built, but how quality is maintained +- AI-assisted collaboration standards are explicit, reviewable, and enforceable +- at least two reference slices demonstrate the intended discipline end to end + +## Immediate Execution Order + +1. Batch 1: timeline stability and test closure +2. Batch 2: holiday/vacation parity +3. Batch 3: resource read decomposition +4. Batch 5: CI guardrails +5. Batch 4: notification/task/broadcast residuals +6. Batch 6: operational standardization +7. Batch 7: reference project artifacts + +## Working Notes + +- The demand/assignment migration itself is treated as complete and is not the active blocker anymore. +- The current north star is showcase quality, deterministic behavior, and smaller ownership surfaces. +- If a new bug appears in a P0/P1 UX surface, fix it before resuming lower-priority batches.