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
  two real dnd-kit drag tests driven through the keyboard sensor (Space +
  ArrowUp + Space on the row's grip handle):
  - "Schedule drag-reorder: cancelled rows ... do not disturb the visible
    chronological order" — cancelled row keeps its slot, visible rows end
    up chronological after the drop.
  - "Schedule drag-reorder: a null-startTime row drags deterministically
    and inherits its new slot" — null-time row dragged to top inherits the
    first chronological slot; the originally-null slot shifts to the row
    that lands at the bottom.

Code-review follow-ups addressed:
- Reverted the unrelated artifacts/tx-os/public/opengraph.jpg binary change.
- Added the explicit null-startTime regressions at both API and Playwright
  drag levels per review request.
- Replaced the fetch-based UI test with real dnd-kit keyboard-sensor drags
  to fully exercise the client onDragEnd path including the patched index
  math.

All 8 reorder API tests and both new Playwright drag 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:25:37 +00:00
parent 360b231693
commit b880dd3c37
@@ -993,6 +993,97 @@ test("Schedule drag-reorder: cancelled rows on the same day do not disturb the v
expect(bAfter.daily_number).toBe(bBefore.daily_number);
});
test("Schedule drag-reorder: a null-startTime row drags deterministically and inherits its new slot", async ({
page,
}) => {
// The slot-swap sort puts non-null startTimes first (chronological)
// and null-startTime rows last. After dragging the null row to the
// top of a 3-row visible day, it should inherit the first
// chronological slot (09:00) and the originally-null slot should
// shift to the bottom row.
await setLangEn(page);
const date = uniqueFutureDate(25);
const a = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `NullA ${Date.now().toString(36)}`,
titleAr: `بلاأ ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "09:30:00",
});
const b = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `NullB ${Date.now().toString(36)}`,
titleAr: `بلاب ${Date.now().toString(36)}`,
startTime: "10:00:00",
endTime: "10:30:00",
});
const n = await insertMeeting({
meetingDate: date,
dailyNumber: 3,
titleEn: `NullN ${Date.now().toString(36)}`,
titleAr: `بلان ${Date.now().toString(36)}`,
startTime: null,
endTime: null,
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
const rowA = page.getByTestId(`em-row-${a}`);
const rowB = page.getByTestId(`em-row-${b}`);
const rowN = page.getByTestId(`em-row-${n}`);
await expect(rowA).toBeVisible({ timeout: 10_000 });
await expect(rowB).toBeVisible();
await expect(rowN).toBeVisible();
await ensureEditMode(page);
const gripN = page.getByTestId(`em-row-grip-${n}`);
await expect(gripN).toBeVisible();
await gripN.focus();
const savePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
resp.request().method() === "POST" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
const refetchPromise = waitForDayRefetch(page, date);
await page.keyboard.press("Space"); // pick up N (last row)
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(150);
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(150);
await page.keyboard.press("Space"); // drop -> visible order [N, A, B]
await Promise.all([savePromise, refetchPromise]);
// Slots sorted chronologically (nulls last) were
// slot[0] = (09:00, dn=A's), slot[1] = (10:00, dn=B's), slot[2] = (null, dn=N's).
// Assignment N->slot[0], A->slot[1], B->slot[2] yields the row times below.
const { rows: visibleAfter } = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings
WHERE meeting_date = $1
ORDER BY daily_number ASC`,
[date],
);
const byId = Object.fromEntries(visibleAfter.map((r) => [r.id, r]));
expect(String(byId[n].start_time)).toBe("09:00:00");
expect(String(byId[a].start_time)).toBe("10:00:00");
expect(byId[b].start_time).toBe(null);
// dailyNumbers are a unique permutation 1..3 across the day.
const dns = visibleAfter.map((r) => r.daily_number).slice().sort();
expect(dns).toEqual([1, 2, 3]);
});
test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({
page,
}) => {