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.
This commit is contained in:
Riyadh
2026-05-11 15:18:06 +00:00
parent 9c3985b807
commit 26393ec155
6 changed files with 291 additions and 480 deletions
@@ -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
-6
View File
@@ -1433,12 +1433,6 @@
"moveDown": "نقل لأسفل",
"postpone": "تأجيل"
},
"rotate": {
"needsTimeWindow": {
"tooltip": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم.",
"errorToast": "حدّد وقت بداية ونهاية لكل اجتماع قبل إعادة ترتيب هذا اليوم."
}
},
"merge": {
"label": "دمج الخلايا",
"editLabel": "تعديل الخلية المدموجة",
-6
View File
@@ -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",
+26 -141
View File
@@ -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({
<div className="text-sm">{date}</div>
</div>
{/* #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 (
<Tooltip open={open} onOpenChange={setOpen} delayDuration={150}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
title={label}
data-testid={`em-row-drag-blocked-info-${meetingId}`}
className="inline-flex items-center justify-center text-amber-600 hover:text-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-400 rounded p-0.5"
// Prevent the click from bubbling into the row-level
// quick-actions handler or a parent dnd listener.
onClick={(e) => {
e.stopPropagation();
setOpen((v) => !v);
}}
onPointerDown={(e) => e.stopPropagation()}
>
<AlertTriangle className="w-4 h-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
data-testid={`em-row-drag-blocked-tooltip-${meetingId}`}
>
{label}
</TooltipContent>
</Tooltip>
);
}
// #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 <tr> 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({
*/}
<div className="flex items-center justify-center gap-1">
<span>{displayNumber ?? meeting.dailyNumber}</span>
{dragBlockedByMissingTime ? (
// #492: explicit Tooltip-wrapped warning affordance.
// Native `title` on the <tr> 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.
<DragBlockedTooltipButton
meetingId={meeting.id}
label={t(
"executiveMeetings.rotate.needsTimeWindow.tooltip",
)}
/>
) : null}
</div>
{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
// <tr> in Radix's Tooltip primitive is fragile because
// TooltipTrigger renders its own element wrapper.
{...(dragBlockedByMissingTime ? { "aria-disabled": true } : {})}
>
{renderCells()}
</tr>
@@ -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);
}
});
@@ -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 <tr> 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");
});