#486: Executive Meetings row click → quick-actions popover

Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.

Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
  UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
  conflict payload), date/time-window guards, audit logging, and
  renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.

Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
  swap against the day query cache and rolls back on failure (mirrors
  the existing inline-edit UX).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
  (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
  em-row-grip / em-row-actions data-testid prefixes do NOT open the
  popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.

Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
  (400), no_time_window (400), unauth (401), viewer-no-mutate (403),
  malformed-timestamp (400) — all 7 pass.
- Hardened expectedUpdatedAt zod schema to z.string().datetime() so
  malformed tokens fail at validation with a controlled 400 instead of
  bubbling up as a 500.
- E2E: Move up swap, edge-disable states (solo / first / middle /
  last), Postpone 5-min chip end-to-end, click-exclusion on grip /
  time cell / row-actions — all 4 pass. Each test uses its own future
  date to avoid cross-test pollution.

Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.

Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
This commit is contained in:
riyadhafraa
2026-05-11 11:06:13 +00:00
parent 02b3b374b0
commit 16a818b716
2 changed files with 19 additions and 2 deletions
@@ -354,6 +354,19 @@ test("swap-times: viewer (no mutate role) → 403 forbidden", async () => {
assert.equal(res.status, 403, "viewer must not be able to swap times");
});
test("swap-times: malformed expectedUpdatedAt → 400 from zod, not 500", async () => {
// The route passes expectedUpdatedAt straight to new Date(...).toISOString().
// Without a strict zod guard a non-ISO string would throw and surface as a
// 500. Lock the controlled-failure contract here.
const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
aId: 1,
bId: 2,
expectedUpdatedAtA: "not-a-real-timestamp",
expectedUpdatedAtB: new Date().toISOString(),
});
assert.equal(res.status, 400, "malformed timestamp must be rejected at validation");
});
test("swap-times: meeting without a time window → 400 no_time_window", async () => {
// Create A normally, then null out B's start/end directly so we can
// exercise the guard without going through PATCH (which validates).
+6 -2
View File
@@ -17,8 +17,12 @@ export type ExecutiveMeetingsReorderBodyT = z.infer<
export const ExecutiveMeetingsSwapTimesBody = z.object({
aId: z.number().int().positive(),
bId: z.number().int().positive(),
expectedUpdatedAtA: z.string().min(1),
expectedUpdatedAtB: z.string().min(1),
// Must be ISO-8601 datetime strings — the route passes them straight
// to `new Date(...).toISOString()` and any malformed input would
// bubble up as an uncontrolled 500. z.string().datetime() rejects
// garbage at the validation boundary so the client gets a 400.
expectedUpdatedAtA: z.string().datetime(),
expectedUpdatedAtB: z.string().datetime(),
});
export type ExecutiveMeetingsSwapTimesBodyT = z.infer<