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 a real dnd-kit drag via the
  keyboard sensor (Space + ArrowUp x2 + Space on the row's grip handle),
  exercising the full client onDragEnd path including the patched index
  math.

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.
- Replaced the fetch-based UI test with a real dnd-kit keyboard-sensor
  drag, covering the client index-mapping path end-to-end.

All 8 reorder API tests and the new Playwright drag test 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:22:34 +00:00
parent 22328e7252
commit 360b231693
@@ -924,13 +924,16 @@ test("Schedule drag-reorder: cancelled rows on the same day do not disturb the v
await expect(page.getByTestId(`em-row-${b}`)).toHaveCount(0);
await ensureEditMode(page);
// Drive the reorder through the same in-app callback that dnd-kit
// would invoke on a real drop. We can't reliably perform a pixel-
// accurate dnd-kit drag from headless Chromium against the
// SortableContext, but the server contract under test is identical:
// POST /reorder with orderedIds = [D, A, C]. The patched client
// builds those ids from the VISIBLE list, so this also exercises
// the client's index-math fix end-to-end.
// Drive the reorder through dnd-kit's keyboard sensor — focus the
// grip on the bottom visible row (D) and use Space + ArrowUp twice
// + Space to move it to the top. This exercises the REAL onDragEnd
// callback path (including the client's `orderedMeetings`-based
// index math) end-to-end. Headless pixel drag against
// SortableContext is unreliable, so keyboard drag is the high-
// fidelity equivalent — dnd-kit treats both the same downstream.
const gripD = page.getByTestId(`em-row-grip-${d}`);
await expect(gripD).toBeVisible();
await gripD.focus();
const savePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
@@ -943,19 +946,16 @@ test("Schedule drag-reorder: cancelled rows on the same day do not disturb the v
{ timeout: 15_000 },
);
const refetchPromise = waitForDayRefetch(page, date);
await page.evaluate(
({ url, body }) =>
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
credentials: "include",
}).then((r) => r.text()),
{
url: "/api/executive-meetings/reorder",
body: { meetingDate: date, orderedIds: [d, a, c] },
},
);
await page.keyboard.press("Space"); // pick up D
// Brief settle pause — dnd-kit re-measures the SortableContext after
// pick-up before the keyboard sensor accepts movement keys, so a
// back-to-back ArrowUp can otherwise be dropped on the floor.
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp"); // D over C
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp"); // D over A
await page.waitForTimeout(150);
await page.keyboard.press("Space"); // drop -> visible order [D, A, C]
await Promise.all([savePromise, refetchPromise]);
// After the reorder the visible list must read D, A, C top-to-bottom