fix(executive-meetings): keep visible drag-reorder chronological with cancelled rows

Task #311. The schedule view hides cancelled meetings, but the drag-reorder
path was operating on the raw, unfiltered list — so cancelled rows consumed
time slots and the dnd-kit indices skewed across hidden rows, leaving the
visible list out of chronological order after a drop.

Server (POST /api/executive-meetings/reorder)
- Slot-swap now operates on the in-scope (orderedIds) subset only.
  Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so
  they no longer steal slots from the visible list.
- New 400 codes:
  - cancelled_in_reorder: payload includes a cancelled meeting
  - incomplete_day: any non-cancelled meeting on the day is missing
- Audit oldValue.order now reflects the visible order the user actually saw.
- Phase-1/phase-2 negative-parking still avoids transient unique-constraint
  conflicts; cancelled rows' positive dailyNumbers cannot collide because
  slot dailyNumbers are a permutation of in-scope rows' existing values.

Client (executive-meetings.tsx reorderRows)
- Index math now derives ids from `orderedMeetings` (the visible list bound
  to SortableContext) instead of the raw `meetings` array. useCallback deps
  updated.

Tests
- artifacts/api-server/tests/executive-meetings.test.mjs: added
  "leaves cancelled rows untouched and only slot-swaps visible meetings"
  and "rejects orderedIds containing a cancelled meeting".
- artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added
  "Schedule drag-reorder: cancelled rows on the same day do not disturb the
  visible chronological order" (drives reorder via authenticated fetch since
  dnd-kit pixel drag is unreliable in headless).

All 7 reorder tests pass. Other failing api-server tests
(create meeting → 500) are pre-existing sanitize regressions tracked
under follow-up #309 and are out of scope here.
This commit is contained in:
riyadhafraa
2026-05-02 09:15:53 +00:00
parent 4e8beaaa76
commit d366dc076c
5 changed files with 343 additions and 12 deletions
@@ -1094,6 +1094,129 @@ test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids"
"duplicate ids must be reported with the duplicate_ids code");
});
// Task #311: dragging a visible meeting from one row to another must not
// disturb cancelled rows on the same day. The schedule view hides
// cancelled meetings (#273), so dnd-kit can only ever drag visible
// rows; the reorder endpoint must accept an orderedIds payload that
// covers exactly the visible (non-cancelled) meetings, slot-swap among
// them, and leave cancelled rows' time slots and daily numbers
// completely untouched. Before the fix, cancelled meetings were
// included in the slot pool and silently stole chronologically-sorted
// slots from the visible list, which made dragging a meeting from top
// to bottom land it in a non-chronological position on screen.
test("Reorder: leaves cancelled rows untouched and only slot-swaps visible meetings", async () => {
const reorderDate = "2050-05-21";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
const d = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "د", titleEn: "D", meetingDate: reorderDate,
startTime: "12:00", endTime: "12:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
const D = await d.json(); created.meetingIds.push(D.id);
// Cancel B in-place so the visible list is [A, C, D] but the day
// still holds 4 rows. Capture B's pre-reorder slot to assert it
// stays put after the reorder.
await pool.query(
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
[B.id],
);
const { rows: bBeforeRows } = await pool.query(
`SELECT daily_number, start_time, end_time, status
FROM executive_meetings WHERE id = $1`,
[B.id],
);
const bBefore = bBeforeRows[0];
// User drags D (bottom of the visible list) up to the top, so the
// visible order becomes [D, A, C]. The orderedIds payload covers
// ONLY visible meetings — that is what the patched client sends.
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [D.id, A.id, C.id] });
assert.equal(reorder.status, 200,
"subset reorder (cancelled excluded) must be accepted");
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${reorderDate}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
// Visible meetings inherit each other's slots, sorted chronologically:
// D ← (09:00, daily_number from A's slot)
// A ← (11:00, daily_number from C's slot)
// C ← (12:00, daily_number from D's slot)
// After the swap the visible rows must read top-to-bottom in
// chronological start-time order.
const visible = body.meetings
.filter((m) => m.status !== "cancelled")
.sort((a, b) => a.dailyNumber - b.dailyNumber);
assert.deepEqual(visible.map((m) => m.id), [D.id, A.id, C.id],
"visible rows must be in the dropped order [D, A, C]");
const starts = visible.map((m) => m.startTime.slice(0, 5));
for (let i = 1; i < starts.length; i++) {
assert.ok(starts[i - 1] <= starts[i],
`visible row ${i - 1} (${starts[i - 1]}) must be <= row ${i} (${starts[i]}) chronologically`);
}
// Concretely the slot-swap should produce 09:00, 11:00, 12:00 for
// [D, A, C] (B's 10:00 slot stays with B, untouched).
assert.equal(visible[0].startTime.slice(0, 5), "09:00");
assert.equal(visible[1].startTime.slice(0, 5), "11:00");
assert.equal(visible[2].startTime.slice(0, 5), "12:00");
// Cancelled B: time slot AND daily_number must be unchanged.
const bAfter = byId.get(B.id);
assert.equal(bAfter.status, "cancelled");
assert.equal(bAfter.startTime.slice(0, 5),
String(bBefore.start_time).slice(0, 5),
"cancelled meeting startTime must not change");
assert.equal(bAfter.endTime.slice(0, 5),
String(bBefore.end_time).slice(0, 5),
"cancelled meeting endTime must not change");
assert.equal(bAfter.dailyNumber, bBefore.daily_number,
"cancelled meeting dailyNumber must not change");
});
test("Reorder: rejects orderedIds containing a cancelled meeting with code cancelled_in_reorder", async () => {
const reorderDate = "2050-05-22";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
await pool.query(
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
[B.id],
);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [B.id, A.id] });
assert.equal(r.status, 400,
"orderedIds containing a cancelled meeting must be rejected");
const body = await r.json();
assert.equal(body.code, "cancelled_in_reorder",
"rejection must be reported with the cancelled_in_reorder code");
});
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
const reorderDate = "2050-02-20";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {