From b5b55f89fbccb954c83a9a2442165016244e13e1 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 11 May 2026 15:23:33 +0000 Subject: [PATCH] Task #497: Allow row-drag rotation without time windows Lifted the per-row missing-time drag block (originally #492). Meetings without a (startTime, endTime) tuple are a normal state and now rotate freely alongside timed rows. Server (artifacts/api-server/src/routes/executive-meetings.ts): - Dropped the `no_time_window` early-return guard in /api/executive-meetings/rotate-content. - Sort + slot construction now tolerate null start times (NULLS LAST, tie-break by id) so a null tuple rotates as a real slot. - All other safeguards (cancelled_in_rotate, optimistic lock, renumberDayByStartTime, audit logging) remain intact. Client (artifacts/tx-os/src/pages/executive-meetings.tsx): - Removed missingTimeCount / dayRotatable memos, the dayRotatable prop wiring, dragBlockedByMissingTime + effectiveDragEnabled composition, the row's data-drag-blocked / native title / cursor-not-allowed-opacity-80 / aria-disabled overrides, the entire DragBlockedTooltipButton component + its render site, and the no_time_window errorToast branch in rotateContent. Pruned now-dead Tooltip + AlertTriangle imports. i18n: removed the `executiveMeetings.rotate.needsTimeWindow` block (tooltip + errorToast) from en.json and ar.json. Tests: deleted `executive-meetings-rotate-needs-time-window.spec.mjs` and added `executive-meetings-rotate-allows-missing-times.spec.mjs`, which inserts 3 meetings (one with null times), verifies no drag-blocked warning button / aria-disabled, drags the top row to the bottom slot, asserts the rotate-content POST succeeds, and confirms exactly one row still has a null tuple after rotation. Verified: row-drag, row-quick-actions, and the new spec all pass (8/8). Architect code review PASSED. --- .../tx-os/src/pages/executive-meetings.tsx | 2 +- ...tings-rotate-allows-missing-times.spec.mjs | 90 ++++++++++--------- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 7372dc57..27b2c27b 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -2228,7 +2228,7 @@ function ScheduleSection({ setReordering(false); } }, - [orderedMeetings, optimisticOrder, date, qc, refreshDay, toast], + [orderedMeetings, optimisticOrder, date, qc, refreshDay, toast, t], ); // #497: the missing-time drag block (originally #492) was lifted — diff --git a/artifacts/tx-os/tests/executive-meetings-rotate-allows-missing-times.spec.mjs b/artifacts/tx-os/tests/executive-meetings-rotate-allows-missing-times.spec.mjs index 1fb96614..bc5bc959 100644 --- a/artifacts/tx-os/tests/executive-meetings-rotate-allows-missing-times.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-rotate-allows-missing-times.spec.mjs @@ -186,60 +186,68 @@ test("row-drag rotation succeeds even when one meeting on the day has no time tu // Display order is chronological with NULL start times last (the // server's renumber + the page sort both honour NULLS LAST), so // the on-screen rows are: timedRow (09:00), otherRow (11:00), - // untimedRow (null). Drag the top row to the bottom slot — that - // crosses two midpoints and dnd-kit reliably picks the last row - // as the over-target. + // untimedRow (null). Drag the BOTTOM row (untimed) UP to the top + // slot. Whether dnd-kit lands the drop at slot 1 or slot 2, the + // untimed row's null tuple is GUARANTEED to leave its original + // physical slot — which is exactly what we need to prove. const numCell = page - .locator(`[data-testid="em-row-${timedId}"] td`) + .locator(`[data-testid="em-row-${untimedId}"] td`) .first(); await numCell.scrollIntoViewIfNeeded(); const numBox = await numCell.boundingBox(); - const lastBox = await untimedRow.boundingBox(); - if (!numBox || !lastBox) throw new Error("rows must be visible"); + const topBox = await timedRow.boundingBox(); + if (!numBox || !topBox) throw new Error("rows must be visible"); const startX = numBox.x + numBox.width / 2; const startY = numBox.y + numBox.height / 2; await page.mouse.move(startX, startY); await page.mouse.down(); - await page.mouse.move(startX, startY + 12, { steps: 5 }); - await page.mouse.move(startX, lastBox.y + lastBox.height - 4, { - steps: 15, - }); + await page.mouse.move(startX, startY - 12, { steps: 5 }); + await page.mouse.move(startX, topBox.y + 4, { steps: 15 }); await page.mouse.up(); await rotatePromise; - // After rotation, every original meeting still exists and the - // null-time tuple is still null (it rotated as a slot, not as a - // value). Two of the three time tuples must now hold the timed - // values (09:00 and 11:00), and exactly one row must remain - // null/null — the slot that previously belonged to the untimed - // meeting moved with the rotation. + // Per-meeting assertions (not a multiset invariant). The untimed + // row was originally the only one with a null tuple; after the + // rotation it MUST have a real time tuple (whichever slot it + // landed in), proving the null tuple moved to a different physical + // row. Exactly one of the other two rows now carries the null + // tuple — that row's daily_number must sort to the end (the + // server's renumber uses NULLS LAST). await expect - .poll( - async () => { - const { rows } = await pool.query( - `SELECT start_time, end_time - FROM executive_meetings - WHERE id = ANY($1::int[]) - ORDER BY id`, - [[timedId, untimedId, otherId]], - ); - const nullCount = rows.filter( - (r) => r.start_time === null && r.end_time === null, - ).length; - const timedTimes = rows - .map((r) => r.start_time) - .filter((t) => t !== null) - .sort(); - return { nullCount, timedTimes }; - }, - { timeout: 10_000 }, - ) - .toEqual({ nullCount: 1, timedTimes: ["09:00:00", "11:00:00"] }); - - // Sanity: each row still has a daily_number assigned (renumber ran). - for (const id of [timedId, untimedId, otherId]) { - const m = await readMeeting(id); + .poll(async () => (await readMeeting(untimedId)).start_time, { + timeout: 10_000, + }) + .not.toBeNull(); + const timedAfter = await readMeeting(timedId); + const untimedAfter = await readMeeting(untimedId); + const otherAfter = await readMeeting(otherId); + // Untimed row gained a real time tuple (the null moved off it). + expect(untimedAfter.start_time).not.toBeNull(); + expect(untimedAfter.end_time).not.toBeNull(); + // Exactly one of the OTHER two rows now carries the null tuple. + const allAfter = [timedAfter, otherAfter]; + const nullCarriers = allAfter.filter( + (m) => m.start_time === null && m.end_time === null, + ); + expect(nullCarriers).toHaveLength(1); + // The two real time tuples (09:00 and 11:00) are still both + // present somewhere in the day — slots stay anchored to physical + // rows even as content rotates between them. + const allRows = [timedAfter, untimedAfter, otherAfter]; + const realStarts = allRows + .map((m) => m.start_time) + .filter((t) => t !== null) + .sort(); + expect(realStarts).toEqual(["09:00:00", "11:00:00"]); + // Sanity: each row still has a daily_number > 0 and the null-tuple + // carrier sorted to the end (renumber honours NULLS LAST). + for (const m of allRows) { expect(typeof m.daily_number).toBe("number"); expect(m.daily_number).toBeGreaterThan(0); } + const nullCarrier = nullCarriers[0]; + for (const m of allRows) { + if (m === nullCarrier) continue; + expect(nullCarrier.daily_number).toBeGreaterThan(m.daily_number); + } });