Task #489: row-wide drag rotates meeting content; time + daily numbers anchored

Backend
- New POST /api/executive-meetings/rotate-content (zod-validated) rotates
  ONLY meeting content through fixed (start_time, end_time, daily_number)
  slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale;
  incomplete day (missing visible row) → 400.
- ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts).
- 6 backend tests cover happy path, stale, different_dates, 401, 403,
  incomplete_day. Existing /swap-times tests still pass.

Frontend (artifacts/tx-os)
- Whole <tr> is now the drag handle (the dedicated GripVertical button is
  retired). useSortable is gated on canMutate; safeRowDragListeners
  filters drags whose target is an interactive descendant (button, input,
  edit/time cells, row-actions, bulk-select). useSortable `attributes`
  are spread only when canMutate so view-mode rows stay clickable
  (otherwise aria-disabled blocked the popover trigger).
- onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns
  each chronological slot's tuple to the new occupant; rolls back + toast
  on failure.
- Quick-actions popover now contains only Postpone (#486 Move up/down
  buttons removed).

Tests (artifacts/tx-os)
- New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie
  position by # cell, asserts rotate-content fires and slots stay
  anchored.
- tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down
  cases, keeps Postpone + skip-surfaces + viewer.
- tests/executive-meetings-schedule-features.spec.mjs: two legacy grip
  drag tests rewritten to drag the row body and target /rotate-content
  (the legacy /reorder route + tests are intentionally untouched).

Drift / notes
- Architect flagged a medium-severity hardening note: rotate-content
  FOR UPDATE locks orderedIds but not the day-scope completeness query.
  Out of #489 scope; no follow-up created (proposeFollowUpTasks was
  already consumed on #486).
This commit is contained in:
riyadhafraa
2026-05-11 12:14:08 +00:00
parent 82c7894e56
commit f53f7307da
7 changed files with 1192 additions and 382 deletions
+23
View File
@@ -28,3 +28,26 @@ export const ExecutiveMeetingsSwapTimesBody = z.object({
export type ExecutiveMeetingsSwapTimesBodyT = z.infer<
typeof ExecutiveMeetingsSwapTimesBody
>;
// #489: Body for POST /executive-meetings/rotate-content. Used when the
// user drags a meeting from anywhere on its row to a new position. The
// server keeps each meeting's slot (startTime, endTime, dailyNumber)
// anchored to its physical row in chronological order and rotates the
// meeting CONTENT (title, attendees, notes, color, status, merge) so
// that visible position i ends up holding the i-th tuple from the
// original chronological order.
//
// `orderedIds` is the new visible content order (same set as the day's
// non-cancelled meetings — every visible row must appear exactly once).
// `expectedUpdatedAt` carries each visible meeting's last-known
// `updatedAt` ISO string so the server can detect concurrent edits and
// return 409 `stale_meeting` like the other optimistic-locked routes.
export const ExecutiveMeetingsRotateContentBody = z.object({
meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
orderedIds: z.array(z.number().int().positive()).min(2).max(1000),
expectedUpdatedAt: z.record(z.string().regex(/^\d+$/), z.string().datetime()),
});
export type ExecutiveMeetingsRotateContentBodyT = z.infer<
typeof ExecutiveMeetingsRotateContentBody
>;