diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 16d220fe..05e6aabc 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -4300,6 +4300,20 @@ function MeetingRow({ useEffect(() => { if (!effectiveDragEnabled && isPressing) setIsPressing(false); }, [effectiveDragEnabled, isPressing]); + // Defensive cleanup tied to the drag lifecycle: dnd-kit owns the + // touch sequence once a drag activates, and on certain release + // paths (drop outside the row, sensor-managed cancel) the row's + // own `pointerup` / `pointercancel` listeners are not guaranteed + // to fire. We watch `isDragging` for its rising edge (drag just + // activated) AND falling edge (drag just ended) and force-clear + // `isPressing` either way — once the drag itself is the active + // visual signal (opacity 0.5 + dnd-kit overlay), the press ring + // would be redundant; once the drag has ended, any leftover ring + // is stale by definition. + useEffect(() => { + if (!isDragging && !isPressing) return; + if (isPressing) setIsPressing(false); + }, [isDragging]); // eslint-disable-line react-hooks/exhaustive-deps const pressShadow = isPressing ? `inset 0 0 0 2px ${hexToRgba(highlightColor, 0.45)}` : null; diff --git a/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs b/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs index 12bd8422..e4714a93 100644 --- a/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs @@ -163,6 +163,90 @@ test("Touch (iPad): pressing a row paints data-pressing=true within the 200ms To await expect(surface).toBeVisible({ timeout: 5_000 }); }); +test("Touch (iPad): drag activation + drop reliably clears data-pressing (no stale ring after drag)", async ({ + page, +}) => { + // #499 review: dnd-kit owns the touch sequence once a drag + // activates and on some release paths the row's own pointerup + // does not fire. The MeetingRow has a defensive effect that + // watches `isDragging` and force-clears `isPressing` when drag + // activates / ends — this test guards that path: long-press + // (drag activates), move (drag in progress), release (drag ends), + // assert `data-pressing` is cleared after the drop. + await page.addInitScript(() => { + try { + window.localStorage.setItem("tx-lang", "en"); + } catch { + /* ignore */ + } + }); + + const date = uniqueFutureDate(3); + const idA = await insertMeeting({ + meetingDate: date, + titleEn: `Touch Drag Cleanup A ${Date.now().toString(36)}`, + startTime: "13:00:00", + endTime: "14:00:00", + }); + const idB = await insertMeeting({ + meetingDate: date, + titleEn: `Touch Drag Cleanup B ${Date.now().toString(36)}`, + startTime: "15:00:00", + endTime: "16:00:00", + }); + + await loginViaUi(page, "admin", "admin123"); + await page.goto("/executive-meetings"); + await page.locator('input[type="date"]').first().fill(date); + + const rowA = page.getByTestId(`em-row-${idA}`); + const rowB = page.getByTestId(`em-row-${idB}`); + await expect(rowA).toBeVisible({ timeout: 10_000 }); + await expect(rowB).toBeVisible(); + + const numberCellA = rowA.locator("td").first(); + await numberCellA.scrollIntoViewIfNeeded(); + const numBox = await numberCellA.boundingBox(); + const targetBox = await rowB.boundingBox(); + expect(numBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + const startX = numBox.x + numBox.width / 2; + const startY = numBox.y + numBox.height / 2; + const endY = targetBox.y + targetBox.height - 4; + + const cdp = await page.context().newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: startX, y: startY, id: 1 }], + }); + // Hold past 200ms TouchSensor activation delay. + await page.waitForTimeout(350); + // Drag down toward rowB. + const steps = 12; + for (let i = 1; i <= steps; i++) { + const y = startY + ((endY - startY) * i) / steps; + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: startX, y, id: 1 }], + }); + await page.waitForTimeout(20); + } + // Release — drag ends. Whether the row's own pointerup fires or + // dnd-kit cancels it, the `isDragging` cleanup effect must clear + // `data-pressing`. + await cdp.send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + // Both rows should settle without any lingering pressing state. + await expect(rowA).not.toHaveAttribute("data-pressing", "true", { + timeout: 1_000, + }); + await expect(rowB).not.toHaveAttribute("data-pressing", "true", { + timeout: 1_000, + }); +}); + test("Touch (iPad): press cue persists during the long-press hold (>200ms TouchSensor delay)", async ({ page, }) => {