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:23:33 +00:00
parent 59d838b8f2
commit b5b55f89fb
2 changed files with 50 additions and 42 deletions
@@ -2228,7 +2228,7 @@ function ScheduleSection({
setReordering(false);
}
},
[orderedMeetings, optimisticOrder, date, qc, refreshDay, toast],
[orderedMeetings, optimisticOrder, date, qc, refreshDay, toast, t],
);
// #497: the missing-time drag block (originally #492) was lifted —
@@ -186,60 +186,68 @@ test("row-drag rotation succeeds even when one meeting on the day has no time tu
// Display order is chronological with NULL start times last (the
// server's renumber + the page sort both honour NULLS LAST), so
// the on-screen rows are: timedRow (09:00), otherRow (11:00),
// untimedRow (null). Drag the top row to the bottom slot — that
// crosses two midpoints and dnd-kit reliably picks the last row
// as the over-target.
// untimedRow (null). Drag the BOTTOM row (untimed) UP to the top
// slot. Whether dnd-kit lands the drop at slot 1 or slot 2, the
// untimed row's null tuple is GUARANTEED to leave its original
// physical slot — which is exactly what we need to prove.
const numCell = page
.locator(`[data-testid="em-row-${timedId}"] td`)
.locator(`[data-testid="em-row-${untimedId}"] td`)
.first();
await numCell.scrollIntoViewIfNeeded();
const numBox = await numCell.boundingBox();
const lastBox = await untimedRow.boundingBox();
if (!numBox || !lastBox) throw new Error("rows must be visible");
const topBox = await timedRow.boundingBox();
if (!numBox || !topBox) throw new Error("rows must be visible");
const startX = numBox.x + numBox.width / 2;
const startY = numBox.y + numBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 12, { steps: 5 });
await page.mouse.move(startX, lastBox.y + lastBox.height - 4, {
steps: 15,
});
await page.mouse.move(startX, startY - 12, { steps: 5 });
await page.mouse.move(startX, topBox.y + 4, { steps: 15 });
await page.mouse.up();
await rotatePromise;
// After rotation, every original meeting still exists and the
// null-time tuple is still null (it rotated as a slot, not as a
// value). Two of the three time tuples must now hold the timed
// values (09:00 and 11:00), and exactly one row must remain
// null/null — the slot that previously belonged to the untimed
// meeting moved with the rotation.
// Per-meeting assertions (not a multiset invariant). The untimed
// row was originally the only one with a null tuple; after the
// rotation it MUST have a real time tuple (whichever slot it
// landed in), proving the null tuple moved to a different physical
// row. Exactly one of the other two rows now carries the null
// tuple — that row's daily_number must sort to the end (the
// server's renumber uses NULLS LAST).
await expect
.poll(
async () => {
const { rows } = await pool.query(
`SELECT start_time, end_time
FROM executive_meetings
WHERE id = ANY($1::int[])
ORDER BY id`,
[[timedId, untimedId, otherId]],
);
const nullCount = rows.filter(
(r) => r.start_time === null && r.end_time === null,
).length;
const timedTimes = rows
.map((r) => r.start_time)
.filter((t) => t !== null)
.sort();
return { nullCount, timedTimes };
},
{ timeout: 10_000 },
)
.toEqual({ nullCount: 1, timedTimes: ["09:00:00", "11:00:00"] });
// Sanity: each row still has a daily_number assigned (renumber ran).
for (const id of [timedId, untimedId, otherId]) {
const m = await readMeeting(id);
.poll(async () => (await readMeeting(untimedId)).start_time, {
timeout: 10_000,
})
.not.toBeNull();
const timedAfter = await readMeeting(timedId);
const untimedAfter = await readMeeting(untimedId);
const otherAfter = await readMeeting(otherId);
// Untimed row gained a real time tuple (the null moved off it).
expect(untimedAfter.start_time).not.toBeNull();
expect(untimedAfter.end_time).not.toBeNull();
// Exactly one of the OTHER two rows now carries the null tuple.
const allAfter = [timedAfter, otherAfter];
const nullCarriers = allAfter.filter(
(m) => m.start_time === null && m.end_time === null,
);
expect(nullCarriers).toHaveLength(1);
// The two real time tuples (09:00 and 11:00) are still both
// present somewhere in the day — slots stay anchored to physical
// rows even as content rotates between them.
const allRows = [timedAfter, untimedAfter, otherAfter];
const realStarts = allRows
.map((m) => m.start_time)
.filter((t) => t !== null)
.sort();
expect(realStarts).toEqual(["09:00:00", "11:00:00"]);
// Sanity: each row still has a daily_number > 0 and the null-tuple
// carrier sorted to the end (renumber honours NULLS LAST).
for (const m of allRows) {
expect(typeof m.daily_number).toBe("number");
expect(m.daily_number).toBeGreaterThan(0);
}
const nullCarrier = nullCarriers[0];
for (const m of allRows) {
if (m === nullCarrier) continue;
expect(nullCarrier.daily_number).toBeGreaterThan(m.daily_number);
}
});