Task #497: Allow row-drag rotation without time windows

Lifted the per-row missing-time drag block (originally #492). Meetings
without a (startTime, endTime) tuple are a normal state and now rotate
freely alongside timed rows.

Server (artifacts/api-server/src/routes/executive-meetings.ts):
- Dropped the `no_time_window` early-return guard in
  /api/executive-meetings/rotate-content.
- Sort + slot construction now tolerate null start times (NULLS LAST,
  tie-break by id) so a null tuple rotates as a real slot.
- All other safeguards (cancelled_in_rotate, optimistic lock,
  renumberDayByStartTime, audit logging) remain intact.

Client (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Removed missingTimeCount / dayRotatable memos, the dayRotatable
  prop wiring, dragBlockedByMissingTime + effectiveDragEnabled
  composition, the row's data-drag-blocked / native title /
  cursor-not-allowed-opacity-80 / aria-disabled overrides, the entire
  DragBlockedTooltipButton component + its render site, and the
  no_time_window errorToast branch in rotateContent. Pruned now-dead
  Tooltip + AlertTriangle imports.

i18n: removed the `executiveMeetings.rotate.needsTimeWindow` block
(tooltip + errorToast) from en.json and ar.json.

Tests: deleted `executive-meetings-rotate-needs-time-window.spec.mjs`
and added `executive-meetings-rotate-allows-missing-times.spec.mjs`,
which inserts 3 meetings (one with null times), verifies no
drag-blocked warning button / aria-disabled, drags the top row to the
bottom slot, asserts the rotate-content POST succeeds, and confirms
exactly one row still has a null tuple after rotation.

Verified: row-drag, row-quick-actions, and the new spec all pass
(8/8). Architect code review PASSED.
This commit is contained in:
riyadhafraa
2026-05-11 15:18:06 +00:00
parent e59b4919ec
commit 59d838b8f2
6 changed files with 291 additions and 480 deletions
@@ -2454,19 +2454,11 @@ router.post(
code: "cancelled_in_rotate",
};
}
// Time window guard: every meeting in the rotation must already
// have a (startTime, endTime) — rotation only makes sense when
// every slot has a tuple to hand off.
for (const r of lockedRows) {
if (r.startTime == null || r.endTime == null) {
return {
ok: false,
status: 400,
error: "Every meeting must have a scheduled time window to rotate",
code: "no_time_window",
};
}
}
// #497: the previous `no_time_window` guard was removed —
// meetings without a (startTime, endTime) tuple are a perfectly
// normal state and the rotation algorithm below handles `null`
// tuples just like real ones (a `null/null` slot rotates to its
// new physical row alongside the timed slots).
// Optimistic-lock check on every meeting in the rotation. Same
// shape as swap-times so the client can render the same conflict
// toast.
@@ -2514,15 +2506,25 @@ router.post(
}
// Build the canonical chronological slot order. Sort by
// (startTime, then id) so the slot[i] tuple is deterministic
// even when two rows share a start time.
// even when two rows share a start time. #497: null start times
// sort LAST (after every timed row) and tie-break by id so the
// ordering is stable across runs. This keeps the visual
// chronological order matching what the client renders (timed
// rows first in time order, untimed rows trailing by insertion).
const chrono = lockedRows.slice().sort((a, b) => {
if (a.startTime! < b.startTime!) return -1;
if (a.startTime! > b.startTime!) return 1;
const aNull = a.startTime == null;
const bNull = b.startTime == null;
if (aNull && !bNull) return 1;
if (!aNull && bNull) return -1;
if (!aNull && !bNull) {
if (a.startTime! < b.startTime!) return -1;
if (a.startTime! > b.startTime!) return 1;
}
return a.id - b.id;
});
const slots = chrono.map((r) => ({
startTime: r.startTime!,
endTime: r.endTime!,
startTime: r.startTime,
endTime: r.endTime,
}));
// Two-phase write so we don't transiently violate the per-day
// unique index on dailyNumber: park every row on a temporary