From 26393ec1555407620524285f400d4423593ddab4 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 11 May 2026 15:18:06 +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. --- .../src/routes/executive-meetings.ts | 38 ++- artifacts/tx-os/src/locales/ar.json | 6 - artifacts/tx-os/src/locales/en.json | 6 - .../tx-os/src/pages/executive-meetings.tsx | 167 ++-------- ...tings-rotate-allows-missing-times.spec.mjs | 245 ++++++++++++++ ...meetings-rotate-needs-time-window.spec.mjs | 309 ------------------ 6 files changed, 291 insertions(+), 480 deletions(-) create mode 100644 artifacts/tx-os/tests/executive-meetings-rotate-allows-missing-times.spec.mjs delete mode 100644 artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 6cd7a9f8..bc047716 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -2454,19 +2454,11 @@ router.post( code: "cancelled_in_rotate", }; } - // Time window guard: every meeting in the rotation must already - // have a (startTime, endTime) — rotation only makes sense when - // every slot has a tuple to hand off. - for (const r of lockedRows) { - if (r.startTime == null || r.endTime == null) { - return { - ok: false, - status: 400, - error: "Every meeting must have a scheduled time window to rotate", - code: "no_time_window", - }; - } - } + // #497: the previous `no_time_window` guard was removed — + // meetings without a (startTime, endTime) tuple are a perfectly + // normal state and the rotation algorithm below handles `null` + // tuples just like real ones (a `null/null` slot rotates to its + // new physical row alongside the timed slots). // Optimistic-lock check on every meeting in the rotation. Same // shape as swap-times so the client can render the same conflict // toast. @@ -2514,15 +2506,25 @@ router.post( } // Build the canonical chronological slot order. Sort by // (startTime, then id) so the slot[i] tuple is deterministic - // even when two rows share a start time. + // even when two rows share a start time. #497: null start times + // sort LAST (after every timed row) and tie-break by id so the + // ordering is stable across runs. This keeps the visual + // chronological order matching what the client renders (timed + // rows first in time order, untimed rows trailing by insertion). const chrono = lockedRows.slice().sort((a, b) => { - if (a.startTime! < b.startTime!) return -1; - if (a.startTime! > b.startTime!) return 1; + const aNull = a.startTime == null; + const bNull = b.startTime == null; + if (aNull && !bNull) return 1; + if (!aNull && bNull) return -1; + if (!aNull && !bNull) { + if (a.startTime! < b.startTime!) return -1; + if (a.startTime! > b.startTime!) return 1; + } return a.id - b.id; }); const slots = chrono.map((r) => ({ - startTime: r.startTime!, - endTime: r.endTime!, + startTime: r.startTime, + endTime: r.endTime, })); // Two-phase write so we don't transiently violate the per-day // unique index on dailyNumber: park every row on a temporary diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index a262f055..88aaebb2 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1433,12 +1433,6 @@ "moveDown": "نقل لأسفل", "postpone": "تأجيل" }, - "rotate": { - "needsTimeWindow": { - "tooltip": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم.", - "errorToast": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم." - } - }, "merge": { "label": "دمج الخلايا", "editLabel": "تعديل الخلية المدموجة", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 4a3fa695..b1af1c69 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1147,12 +1147,6 @@ "moveDown": "Move down", "postpone": "Postpone" }, - "rotate": { - "needsTimeWindow": { - "tooltip": "Set a start and end time on every meeting before you can reorder this day.", - "errorToast": "Set a start and end time on every meeting before you can reorder this day." - } - }, "merge": { "label": "Merge cells", "editLabel": "Edit merged cell", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 77d159ea..7372dc57 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -109,12 +109,7 @@ import { sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { GripVertical, AlertTriangle } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { GripVertical } from "lucide-react"; import { EditableCell } from "@/components/editable-cell"; // #486: reuse the same dialog the upcoming-alert pops, so postponing // from the schedule row quick-actions menu shares one implementation. @@ -2218,20 +2213,11 @@ function ScheduleSection({ // #489: restore the prior optimistic order on rollback so a // failed rotate doesn't leave the UI in the post-drop order. setOptimisticOrder(previousOptimisticOrder); - // #492: localize the server's `no_time_window` 400 — the raw - // English error string ("Every meeting must have a scheduled - // time window to rotate") was leaking into the toast for AR - // users and confusing everyone. The new tooltip + inline hint - // already explain the gating in both languages, so the toast - // mirrors that copy on this specific error code. - const code = (err as { code?: unknown } | null)?.code; - const isNoTimeWindow = - typeof code === "string" && code === "no_time_window"; - const msg = isNoTimeWindow - ? t("executiveMeetings.rotate.needsTimeWindow.errorToast") - : err instanceof Error - ? err.message - : String(err); + // #497: the previous `no_time_window` toast branch was + // removed — the server no longer rejects rotations that + // include a row with a NULL time tuple, so this code path is + // unreachable. Surface whatever message the error carries. + const msg = err instanceof Error ? err.message : String(err); toast({ title: t("common.error"), description: msg, @@ -2242,23 +2228,14 @@ function ScheduleSection({ setReordering(false); } }, - [orderedMeetings, optimisticOrder, date, qc, refreshDay, toast, t], + [orderedMeetings, optimisticOrder, date, qc, refreshDay, toast], ); - // #492: a row-drag rotation is only meaningful when EVERY visible - // meeting on the day has both a startTime and endTime — the server's - // /rotate-content endpoint hard-rejects any rotation that includes a - // row with a NULL time tuple (`code: "no_time_window"`). We block the - // drag client-side so the user gets a tooltip + inline hint instead - // of an opaque error toast after they actually try to drag. - const missingTimeCount = useMemo( - () => - orderedMeetings.filter( - (m) => m.startTime == null || m.endTime == null, - ).length, - [orderedMeetings], - ); - const dayRotatable = missingTimeCount === 0; + // #497: the missing-time drag block (originally #492) was lifted — + // meetings without a (startTime, endTime) tuple are a normal state + // and the server's /rotate-content now accepts them. The + // `missingTimeCount` / `dayRotatable` memos that gated drag are + // gone along with the warning button + dimmed-row affordance. // #486 → #489: Move up / Move down were retired in favour of // dragging the whole row (`rotateContent` above). The schedule's @@ -2680,12 +2657,10 @@ function ScheduleSection({
{date}
- {/* #496: the amber missing-time hint banner introduced in #492 - was removed per product feedback — meetings without a - start/end time are a perfectly normal state and don't warrant - a warning. The underlying `dayRotatable` / `missingTimeCount` - gating still aborts row-drag client-side (and per-row tooltip - affordance + native title still explain why on hover). */} + {/* #496/#497: the amber missing-time hint banner (#492) and the + per-row drag block + warning button were both removed — + meetings without a start/end time are a normal state and + rotate freely alongside timed rows. */} {/* Floating bulk-action toolbar. Per spec it appears ONLY when ≥1 row is selected (and in edit mode). The tri-state @@ -2942,13 +2917,6 @@ function ScheduleSection({ // #489: drag must work for mutators regardless // of edit mode — pass raw mutate permission. dragEnabled={canMutate} - // #492: but block the actual drag (with a dimmed, - // aria-disabled row + bilingual tooltip) when any - // meeting on the day is missing a (start, end) - // tuple — the server would 400 `no_time_window` - // anyway. The hint banner above the table tells - // the user how many rows still need a time. - dayRotatable={dayRotatable} onSaveTitle={(html) => saveTitle(m, html)} onSaveAttendeeName={(idx, html) => saveAttendeeName(m, idx, html) @@ -3744,48 +3712,10 @@ function BackToMainButton({ ); } -// #492: Touch-friendly tooltip affordance for the drag-blocked state. -// Radix Tooltip opens on hover/focus by default, but iPad taps don't -// fire those — we toggle `open` on click so a single tap shows the -// localized explanation. The button is rendered inside the row's -// number cell and inherits its dimmed visual state. -function DragBlockedTooltipButton({ - meetingId, - label, -}: { - meetingId: string | number; - label: string; -}) { - const [open, setOpen] = useState(false); - return ( - - - - - - {label} - - - ); -} +// #497: the touch-friendly `DragBlockedTooltipButton` (the per-row +// amber warning surface introduced in #492) was removed alongside +// the missing-time drag gate. Meetings without a start/end time now +// drag freely. function MeetingRow({ meeting, @@ -3815,7 +3745,6 @@ function MeetingRow({ onBulkSelectChange, displayNumber, dragEnabled, - dayRotatable, quickActionsCanMutate, onQuickPostpone, }: { @@ -3834,12 +3763,6 @@ function MeetingRow({ // (which equals canMutate && editMode and powers inline-editing // affordances on cells). dragEnabled: boolean; - // #492: false when ANY meeting on the day lacks a (startTime, - // endTime) — the server's rotate-content endpoint hard-rejects the - // payload, so we block the drag client-side. The row still renders - // (no popover/edit affordances change), but useSortable is disabled - // and the is dimmed + aria-disabled with a bilingual title. - dayRotatable: boolean; // #486/#496: mutate permission for the row's quick-actions surface // (centered Postpone dialog opened on row click). #486 originally // wired this un-gated by editMode; #496 narrows it to @@ -3908,13 +3831,11 @@ function MeetingRow({ // at the page level keep clicks/taps on inert cell area from being // mistaken for drags, so the quick-actions popover still opens on // tap-and-release. - // #492: actual drag is gated by BOTH the user's mutate permission - // (dragEnabled) AND every meeting on the day having a (start, end) - // tuple (dayRotatable). When dragEnabled is true but dayRotatable - // is false we still show a "would be draggable" affordance, just - // dimmed + cursor-not-allowed with a tooltip explaining why. - const effectiveDragEnabled = dragEnabled && dayRotatable; - const dragBlockedByMissingTime = dragEnabled && !dayRotatable; + // #497: drag is gated only by the user's mutate permission. The + // missing-time block (#492) is gone — every visible row is + // draggable when the user has mutate access, regardless of whether + // the meetings on the day have start/end times. + const effectiveDragEnabled = dragEnabled; const { attributes, listeners, @@ -4160,19 +4081,6 @@ function MeetingRow({ */}
{displayNumber ?? meeting.dailyNumber} - {dragBlockedByMissingTime ? ( - // #492: explicit Tooltip-wrapped warning affordance. - // Native `title` on the works on desktop hover but - // is unreliable on iPad long-press; this Radix tooltip - // opens on hover, focus, AND tap (button onClick toggles - // open state) so touch users can read why drag is paused. - - ) : null}
{cellBulkOverlay} {actionsOverlay} @@ -4437,39 +4345,16 @@ function MeetingRow({ ref={setNodeRef} className={ "group" + - (effectiveDragEnabled - ? " cursor-grab active:cursor-grabbing" - : dragBlockedByMissingTime - ? " cursor-not-allowed opacity-80" - : "") + (effectiveDragEnabled ? " cursor-grab active:cursor-grabbing" : "") } style={trStyle} data-testid={`em-row-${meeting.id}`} data-current-meeting={isCurrent ? "true" : undefined} data-bulk-selected={bulkSelected ? "true" : undefined} data-quick-open={quickActionsCanMutate && quickOpen ? "true" : undefined} - data-drag-blocked={ - dragBlockedByMissingTime ? "no_time_window" : undefined - } - title={ - dragBlockedByMissingTime - ? t("executiveMeetings.rotate.needsTimeWindow.tooltip") - : undefined - } onClick={quickActionsCanMutate ? handleRowClick : undefined} {...(dragEnabled ? attributes : {})} {...(safeRowDragListeners ?? {})} - // #492: aria-disabled communicates the row's blocked drag - // state to assistive tech. dnd-kit's `attributes` spread - // above already sets aria-disabled based on useSortable's - // own `disabled` flag, so this MUST come after the spread to - // win. We force it to "true" only when drag is blocked by - // missing-time (not when blocked by lack of mutate perms, - // which is the existing dnd-kit behaviour). Native `title` - // (above) provides the bilingual hover tooltip — wrapping a - // in Radix's Tooltip primitive is fragile because - // TooltipTrigger renders its own element wrapper. - {...(dragBlockedByMissingTime ? { "aria-disabled": true } : {})} > {renderCells()} 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 new file mode 100644 index 00000000..1fb96614 --- /dev/null +++ b/artifacts/tx-os/tests/executive-meetings-rotate-allows-missing-times.spec.mjs @@ -0,0 +1,245 @@ +// #497: e2e for the new rotation behaviour. Originally (#492) the +// client + server hard-blocked row-drag rotation if ANY meeting on +// the day was missing a (start_time, end_time) tuple — surfaced via +// an amber per-row warning button, dimmed/aria-disabled rows, and a +// `code: "no_time_window"` 400 from the server. Per product feedback +// that block was lifted: meetings without a time tuple are a normal +// state and rotate freely alongside timed rows. This spec verifies: +// * no per-row drag-blocked warning button is rendered; +// * the row is NOT aria-disabled and has no `data-drag-blocked`; +// * dragging a row past an untimed sibling fires a successful +// POST /api/executive-meetings/rotate-content; +// * the untimed slot rotates with the rest — its NULL tuple ends +// up on whichever physical row dnd-kit dropped it on. +import { test, expect } from "@playwright/test"; +import pg from "pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error( + "DATABASE_URL must be set to run the rotate-allows-missing-times test", + ); +} +const pool = new pg.Pool({ connectionString: DATABASE_URL }); +const createdMeetingIds = []; + +function pickFutureDate(offsetDays) { + const d = new Date(Date.now() + offsetDays * 86_400_000); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +const DATE = pickFutureDate(124); + +async function nextDailyNumberForDate(date) { + const { rows } = await pool.query( + `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n + FROM executive_meetings + WHERE meeting_date = $1`, + [date], + ); + return rows[0].n; +} + +async function insertMeeting({ date, titleEn, startTime, endTime }) { + const dn = await nextDailyNumberForDate(date); + const { rows } = await pool.query( + `INSERT INTO executive_meetings + (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status) + VALUES ($1, $2, $2, $3, $4, $5, 'scheduled') + RETURNING id`, + [dn, titleEn, date, startTime, endTime], + ); + const id = rows[0].id; + createdMeetingIds.push(id); + return id; +} + +async function readMeeting(id) { + const { rows } = await pool.query( + `SELECT title_en, start_time, end_time, daily_number + FROM executive_meetings WHERE id = $1`, + [id], + ); + return rows[0]; +} + +async function setLangEn(page) { + await page.addInitScript(() => { + try { + window.localStorage.setItem("tx-lang", "en"); + } catch { + /* ignore */ + } + }); +} + +async function loginViaUi(page, username, password) { + await page.goto("/login"); + await page.locator("#username").fill(username); + await page.locator("#password").fill(password); + await Promise.all([ + page.waitForURL((url) => !url.pathname.endsWith("/login"), { + timeout: 15_000, + }), + page.locator('form button[type="submit"]').click(), + ]); +} + +async function gotoTestDate(page, date) { + await page.goto("/executive-meetings"); + const dateInput = page.locator('input[type="date"]').first(); + await dateInput.waitFor({ state: "visible", timeout: 10_000 }); + await dateInput.fill(date); + await dateInput.dispatchEvent("change"); +} + +test.afterAll(async () => { + if (createdMeetingIds.length > 0) { + await pool.query( + `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, + [createdMeetingIds], + ); + await pool.query( + `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, + [createdMeetingIds], + ); + await pool.query( + `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, + [createdMeetingIds], + ); + } + await pool.end(); +}); + +test("row-drag rotation succeeds even when one meeting on the day has no time tuple", async ({ + page, +}) => { + const timedId = await insertMeeting({ + date: DATE, + titleEn: "MissingTimes Timed", + startTime: "09:00:00", + endTime: "09:30:00", + }); + const untimedId = await insertMeeting({ + date: DATE, + titleEn: "MissingTimes Untimed", + startTime: null, + endTime: null, + }); + const otherId = await insertMeeting({ + date: DATE, + titleEn: "MissingTimes Other", + startTime: "11:00:00", + endTime: "11:30:00", + }); + + await setLangEn(page); + await loginViaUi(page, "admin", "admin123"); + await gotoTestDate(page, DATE); + + // Force EN regardless of the admin user's stored preferredLanguage. + await page.evaluate(async () => { + const w = window; + if (w.i18next && typeof w.i18next.changeLanguage === "function") { + await w.i18next.changeLanguage("en"); + } + }); + + const timedRow = page.getByTestId(`em-row-${timedId}`); + const untimedRow = page.getByTestId(`em-row-${untimedId}`); + const otherRow = page.getByTestId(`em-row-${otherId}`); + await expect(timedRow).toBeVisible({ timeout: 10_000 }); + await expect(untimedRow).toBeVisible(); + await expect(otherRow).toBeVisible(); + + // No row is aria-disabled or marked drag-blocked, and the per-row + // amber warning button is gone (the underlying drag gate was + // lifted in #497). + for (const row of [timedRow, untimedRow, otherRow]) { + const aria = await row.getAttribute("aria-disabled"); + expect(aria === null || aria === "false").toBe(true); + expect(await row.getAttribute("data-drag-blocked")).toBeNull(); + } + for (const id of [timedId, untimedId, otherId]) { + await expect( + page.getByTestId(`em-row-drag-blocked-info-${id}`), + ).toHaveCount(0); + } + + // Drag the timed row down past the untimed row. The rotate-content + // POST must fire and succeed, proving the server no longer rejects + // payloads that include a NULL-time row. + const rotatePromise = page.waitForResponse( + (resp) => { + const u = new URL(resp.url()); + return ( + u.pathname === "/api/executive-meetings/rotate-content" && + resp.request().method() === "POST" && + resp.ok() + ); + }, + { timeout: 20_000 }, + ); + // 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. + const numCell = page + .locator(`[data-testid="em-row-${timedId}"] 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 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.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. + 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); + expect(typeof m.daily_number).toBe("number"); + expect(m.daily_number).toBeGreaterThan(0); + } +}); diff --git a/artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs b/artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs deleted file mode 100644 index 15cdda87..00000000 --- a/artifacts/tx-os/tests/executive-meetings-rotate-needs-time-window.spec.mjs +++ /dev/null @@ -1,309 +0,0 @@ -// #492: e2e for the client-side guard that blocks row-drag rotation -// when ANY meeting on the day is missing a (start_time, end_time) -// tuple. The server's /api/executive-meetings/rotate-content endpoint -// hard-rejects such payloads with `code: "no_time_window"`; before -// #492 the client surfaced the raw English error in a toast even for -// AR users. The fix: -// * disable useSortable on every row of the day (aria-disabled + -// dimmed cursor + bilingual tooltip) so the drag never starts; -// * show an inline amber hint banner above the table with the -// count of meetings still missing a time; -// * once every meeting has a time, drag re-enables and rotation -// succeeds end-to-end. -import { test, expect } from "@playwright/test"; -import pg from "pg"; - -const DATABASE_URL = process.env.DATABASE_URL; -if (!DATABASE_URL) { - throw new Error( - "DATABASE_URL must be set to run the rotate-needs-time-window test", - ); -} -const pool = new pg.Pool({ connectionString: DATABASE_URL }); -const createdMeetingIds = []; - -function pickFutureDate(offsetDays) { - const d = new Date(Date.now() + offsetDays * 86_400_000); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - -const DATE = pickFutureDate(123); - -async function nextDailyNumberForDate(date) { - const { rows } = await pool.query( - `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n - FROM executive_meetings - WHERE meeting_date = $1`, - [date], - ); - return rows[0].n; -} - -async function insertMeeting({ date, titleEn, startTime, endTime }) { - const dn = await nextDailyNumberForDate(date); - const { rows } = await pool.query( - `INSERT INTO executive_meetings - (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status) - VALUES ($1, $2, $2, $3, $4, $5, 'scheduled') - RETURNING id`, - [dn, titleEn, date, startTime, endTime], - ); - const id = rows[0].id; - createdMeetingIds.push(id); - return id; -} - -async function setMeetingTimes(id, startTime, endTime) { - await pool.query( - `UPDATE executive_meetings - SET start_time = $2, end_time = $3, updated_at = NOW() - WHERE id = $1`, - [id, startTime, endTime], - ); -} - -async function readMeeting(id) { - const { rows } = await pool.query( - `SELECT title_en, start_time, end_time, daily_number - FROM executive_meetings WHERE id = $1`, - [id], - ); - return rows[0]; -} - -async function setLangEn(page) { - await page.addInitScript(() => { - try { - window.localStorage.setItem("tx-lang", "en"); - } catch { - /* ignore */ - } - }); -} - -async function loginViaUi(page, username, password) { - await page.goto("/login"); - await page.locator("#username").fill(username); - await page.locator("#password").fill(password); - await Promise.all([ - page.waitForURL((url) => !url.pathname.endsWith("/login"), { - timeout: 15_000, - }), - page.locator('form button[type="submit"]').click(), - ]); -} - -async function gotoTestDate(page, date) { - await page.goto("/executive-meetings"); - const dateInput = page.locator('input[type="date"]').first(); - await dateInput.waitFor({ state: "visible", timeout: 10_000 }); - await dateInput.fill(date); - await dateInput.dispatchEvent("change"); -} - -test.afterAll(async () => { - if (createdMeetingIds.length > 0) { - await pool.query( - `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, - [createdMeetingIds], - ); - await pool.query( - `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, - [createdMeetingIds], - ); - await pool.query( - `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, - [createdMeetingIds], - ); - } - await pool.end(); -}); - -test("blocks row-drag while any meeting on the day is missing a time, then re-enables once times are set", async ({ - page, -}) => { - const timedId = await insertMeeting({ - date: DATE, - titleEn: "NeedsTime Timed", - startTime: "09:00:00", - endTime: "09:30:00", - }); - const untimedId = await insertMeeting({ - date: DATE, - titleEn: "NeedsTime Untimed", - startTime: null, - endTime: null, - }); - const otherId = await insertMeeting({ - date: DATE, - titleEn: "NeedsTime Other", - startTime: "11:00:00", - endTime: "11:30:00", - }); - - await setLangEn(page); - await loginViaUi(page, "admin", "admin123"); - await gotoTestDate(page, DATE); - - // Force EN regardless of the admin user's stored preferredLanguage - // (AuthContext reapplies it on every login, which would otherwise - // flip the page back to AR for the demo admin). - await page.evaluate(async () => { - const w = window; - if (w.i18next && typeof w.i18next.changeLanguage === "function") { - await w.i18next.changeLanguage("en"); - } - }); - - // #496: the amber missing-time hint banner was removed — drag is - // still blocked client-side and surfaced via the per-row tooltip - // affordance asserted further down. We jump straight to the row - // assertions. - - // Every row on the day is aria-disabled for drag (even the rows - // that DO have a time — rotation is whole-day). - const timedRow = page.getByTestId(`em-row-${timedId}`); - const untimedRow = page.getByTestId(`em-row-${untimedId}`); - const otherRow = page.getByTestId(`em-row-${otherId}`); - await expect(timedRow).toHaveAttribute("aria-disabled", "true"); - await expect(untimedRow).toHaveAttribute("aria-disabled", "true"); - await expect(otherRow).toHaveAttribute("aria-disabled", "true"); - await expect(timedRow).toHaveAttribute("data-drag-blocked", "no_time_window"); - - // Touch-friendly tooltip affordance: every blocked row gets a - // warning button inside its # cell. The button's aria-label and - // tooltip body must equal the localized explanation so iPad users - // (where native `title` long-press is unreliable) can read it. - // Accept either AR or EN copy — the demo admin's stored - // preferredLanguage may flip the page between renders, but EITHER - // localization satisfies the bilingual requirement. - const expectedTooltipEn = - "Set a start and end time on every meeting before you can reorder this day."; - const expectedTooltipAr = - "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم."; - const infoBtn = page.getByTestId(`em-row-drag-blocked-info-${timedId}`); - await expect(infoBtn).toBeVisible(); - const ariaLabel = await infoBtn.getAttribute("aria-label"); - expect([expectedTooltipEn, expectedTooltipAr]).toContain(ariaLabel); - // Tap the button → Radix tooltip becomes visible with the same copy. - // The parent has aria-disabled="true" (drag is paused), which - // makes Playwright skip its enabled-actionability check on every - // descendant — the button itself is fully clickable, so we use - // { force: true } to bypass the inherited check. - await infoBtn.click({ force: true }); - const tooltip = page - .getByTestId(`em-row-drag-blocked-tooltip-${timedId}`) - .first(); - await expect(tooltip).toBeVisible({ timeout: 5_000 }); - // Radix Tooltip renders both a visible bubble and a sr-only - // duplicate via aria-describedby, so .textContent() may include - // the localized string twice. Substring-match is sufficient. - const tooltipText = (await tooltip.textContent())?.trim() ?? ""; - expect( - tooltipText.includes(expectedTooltipEn) || - tooltipText.includes(expectedTooltipAr), - ).toBe(true); - // Dismiss the tooltip so it doesn't intercept the drag gesture below. - await infoBtn.click({ force: true }); - - // Attempting to drag must NOT trigger a rotate-content POST. Wire a - // listener; if any rotate request fires during the next ~2s we - // record it. We try the same drag gesture the real e2e drag spec - // uses (drag from the # cell of timedId down past otherId). - let rotateAttempts = 0; - page.on("request", (req) => { - if ( - req.url().includes("/api/executive-meetings/rotate-content") && - req.method() === "POST" - ) { - rotateAttempts += 1; - } - }); - const numCell = page - .locator(`[data-testid="em-row-${timedId}"] td`) - .first(); - const numBox = await numCell.boundingBox(); - const otherBox = await otherRow.boundingBox(); - if (!numBox || !otherBox) 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, otherBox.y + otherBox.height - 4, { - steps: 15, - }); - await page.mouse.up(); - await page.waitForTimeout(800); - expect(rotateAttempts).toBe(0); - - // DB state unchanged. - const timedBefore = await readMeeting(timedId); - expect(timedBefore.start_time).toBe("09:00:00"); - - // Now give the untimed meeting a real time window. Once the day - // becomes fully-timed aria-disabled must lift on every row — the - // actual rotation drag path is already covered by - // executive-meetings-row-drag.spec.mjs, so we only verify the - // block state lifts here. - await setMeetingTimes(untimedId, "10:00:00", "10:30:00"); - await page.reload(); - await page.evaluate(async () => { - const w = window; - if (w.i18next && typeof w.i18next.changeLanguage === "function") { - await w.i18next.changeLanguage("en"); - } - }); - await page.locator('input[type="date"]').first().fill(DATE); - await expect(timedRow).toBeVisible({ timeout: 10_000 }); - await expect(timedRow).not.toHaveAttribute("aria-disabled", "true"); - await expect(timedRow).not.toHaveAttribute( - "data-drag-blocked", - "no_time_window", - ); - await expect( - page.getByTestId(`em-row-drag-blocked-info-${timedId}`), - ).toHaveCount(0); - - // Full unblock flow: drag now succeeds end-to-end. Rotate-content - // POST fires and the timedId row content moves into the next slot - // (start_time becomes the second row's 10:00:00). - const rotatePromise = page.waitForResponse( - (resp) => { - const u = new URL(resp.url()); - return ( - u.pathname === "/api/executive-meetings/rotate-content" && - resp.request().method() === "POST" && - resp.ok() - ); - }, - { timeout: 20_000 }, - ); - const numCell2 = page - .locator(`[data-testid="em-row-${timedId}"] td`) - .first(); - await numCell2.scrollIntoViewIfNeeded(); - const otherRow2 = page.getByTestId(`em-row-${untimedId}`); - const numBox2 = await numCell2.boundingBox(); - const otherBox2 = await otherRow2.boundingBox(); - if (!numBox2 || !otherBox2) throw new Error("rows must be visible"); - const sX = numBox2.x + numBox2.width / 2; - const sY = numBox2.y + numBox2.height / 2; - await page.mouse.move(sX, sY); - await page.mouse.down(); - await page.mouse.move(sX, sY + 12, { steps: 5 }); - await page.mouse.move(sX, otherBox2.y + otherBox2.height - 4, { - steps: 15, - }); - await page.mouse.up(); - await rotatePromise; - - await expect - .poll(async () => (await readMeeting(timedId)).start_time, { - timeout: 10_000, - }) - .toBe("10:00:00"); -});