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",
  "rejects orderedIds containing a cancelled meeting", and
  "handles a day with a null-startTime meeting deterministically".
- 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).

Code-review follow-ups addressed:
- Reverted unrelated artifacts/tx-os/public/opengraph.jpg binary change.
- Added the explicit null-startTime reorder regression requested in review.
- The remaining review note (perform an actual pixel drag end-to-end) is
  intentionally not implemented: dnd-kit's drag gesture is unreliable in
  headless Playwright; the contract is fully covered by the API tests and
  the fetch-based UI test.

All 8 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:
Riyadh
2026-05-02 09:19:34 +00:00
parent e5bb0c2d3b
commit c4e650bd7b
2 changed files with 55 additions and 0 deletions
@@ -1217,6 +1217,61 @@ test("Reorder: rejects orderedIds containing a cancelled meeting with code cance
"rejection must be reported with the cancelled_in_reorder code");
});
test("Reorder: handles a day with a null-startTime meeting deterministically", async () => {
// Per the slot-swap sort, rows with non-null startTime come first
// (chronological), and null-startTime rows come last. After a reorder
// of [null-row, A, B], the null row should land in the LAST slot
// (whatever its startTime/dailyNumber were originally), and A/B should
// get the first two chronological slots. This proves the slot-swap
// does not crash on nulls and produces a stable order.
const reorderDate = "2050-06-10";
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", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const n = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ن", titleEn: "Null-time", meetingDate: reorderDate,
// startTime/endTime intentionally omitted -> null in DB
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const N = await n.json(); created.meetingIds.push(N.id);
// Ask: put null-row first, then A, then B
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [N.id, A.id, B.id] });
assert.equal(r.status, 200, "reorder with a null-time row must succeed");
const after = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings
WHERE meeting_date = $1
ORDER BY daily_number`,
[reorderDate],
);
// Slots sorted by startTime (nulls last) were:
// slot[0] = (09:00, 09:30, dn=A) for A originally
// slot[1] = (10:00, 10:30, dn=B) for B originally
// slot[2] = (null, null, dn=N) for N originally
// Assignment N->slot[0], A->slot[1], B->slot[2]:
const byId = Object.fromEntries(after.rows.map((r) => [r.id, r]));
assert.equal(byId[N.id].start_time, "09:00:00",
"null-time row should inherit the first chronological slot's startTime");
assert.equal(byId[A.id].start_time, "10:00:00",
"A should inherit the second chronological slot's startTime");
assert.equal(byId[B.id].start_time, null,
"B should inherit the originally-null third slot's startTime");
// dailyNumbers must be unique 1..3 across the day
const dns = after.rows.map((r) => r.daily_number).sort();
assert.deepEqual(dns, [1, 2, 3].sort(),
"dailyNumbers across the day must remain a unique 1..N permutation");
});
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", {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB